Browse Source

git web api

main
Silberengel 4 weeks ago
parent
commit
2492c430f9
  1. 248
      NIP-34.md
  2. 678
      package-lock.json
  3. 9
      package.json
  4. 5
      src/hooks.server.ts
  5. 115
      src/lib/components/CodeEditor.svelte
  6. 24
      src/lib/config.ts
  7. 590
      src/lib/services/git/file-manager.ts
  8. 73
      src/lib/services/git/repo-manager.ts
  9. 190
      src/lib/services/nostr/issues-service.ts
  10. 100
      src/lib/services/nostr/maintainer-service.ts
  11. 192
      src/lib/services/nostr/prs-service.ts
  12. 95
      src/lib/services/nostr/repo-verification.ts
  13. 21
      src/lib/types/nostr.ts
  14. 85
      src/routes/+page.svelte
  15. 96
      src/routes/api/repos/[npub]/[repo]/branches/+server.ts
  16. 33
      src/routes/api/repos/[npub]/[repo]/commits/+server.ts
  17. 33
      src/routes/api/repos/[npub]/[repo]/diff/+server.ts
  18. 125
      src/routes/api/repos/[npub]/[repo]/file/+server.ts
  19. 71
      src/routes/api/repos/[npub]/[repo]/issues/+server.ts
  20. 66
      src/routes/api/repos/[npub]/[repo]/maintainers/+server.ts
  21. 77
      src/routes/api/repos/[npub]/[repo]/prs/+server.ts
  22. 96
      src/routes/api/repos/[npub]/[repo]/tags/+server.ts
  23. 32
      src/routes/api/repos/[npub]/[repo]/tree/+server.ts
  24. 103
      src/routes/api/repos/[npub]/[repo]/verify/+server.ts
  25. 19
      src/routes/docs/nip34/+page.server.ts
  26. 185
      src/routes/docs/nip34/+page.svelte
  27. 2017
      src/routes/repos/[npub]/[repo]/+page.svelte
  28. 12
      src/routes/signup/+page.svelte
  29. 248
      static/NIP-34.md

248
NIP-34.md

@ -0,0 +1,248 @@ @@ -0,0 +1,248 @@
NIP-34
======
`git` stuff
-----------
`draft` `optional`
This NIP defines all the ways code collaboration using and adjacent to [`git`](https://git-scm.com/) can be done using Nostr.
## Repository announcements
Git repositories are hosted in Git-enabled servers, but their existence can be announced using Nostr events. By doing so the author asserts themselves as a maintainer and expresses a willingness to receive patches, bug reports and comments in general, unless `t` tag `personal-fork` is included.
```jsonc
{
"kind": 30617,
"content": "",
"tags": [
["d", "<repo-id>"], // usually kebab-case short name
["name", "<human-readable project name>"],
["description", "brief human-readable project description>"],
["web", "<url for browsing>", ...], // a webpage url, if the git server being used provides such a thing
["clone", "<url for git-cloning>", ...], // a url to be given to `git clone` so anyone can clone it
["relays", "<relay-url>", ...], // relays that this repository will monitor for patches and issues
["r", "<earliest-unique-commit-id>", "euc"],
["maintainers", "<other-recognized-maintainer>", ...],
["t","personal-fork"], // optionally indicate author isn't a maintainer
["t", "<arbitrary string>"], // hashtags labelling the repository
]
}
```
The tags `web`, `clone`, `relays`, `maintainers` can have multiple values.
The `r` tag annotated with the `"euc"` marker should be the commit ID of the earliest unique commit of this repo, made to identify it among forks and group it with other repositories hosted elsewhere that may represent essentially the same project. In most cases it will be the root commit of a repository. In case of a permanent fork between two projects, then the first commit after the fork should be used.
Except `d`, all tags are optional.
## Repository state announcements
An optional source of truth for the state of branches and tags in a repository.
```jsonc
{
"kind": 30618,
"content": "",
"tags": [
["d", "<repo-id>"], // matches the identifier in the corresponding repository announcement
["refs/<heads|tags>/<branch-or-tag-name>","<commit-id>"]
["HEAD", "ref: refs/heads/<branch-name>"]
]
}
```
The `refs` tag may appear multiple times, or none.
If no `refs` tags are present, the author is no longer tracking repository state using this event. This approach enables the author to restart tracking state at a later time unlike [NIP-09](09.md) deletion requests.
The `refs` tag can be optionally extended to enable clients to identify how many commits ahead a ref is:
```jsonc
{
"tags": [
["refs/<heads|tags>/<branch-or-tag-name>", "<commit-id>", "<shorthand-parent-commit-id>", "<shorthand-grandparent>", ...],
]
}
```
## Patches and Pull Requests (PRs)
Patches and PRs can be sent by anyone to any repository. Patches and PRs to a specific repository SHOULD be sent to the relays specified in that repository's announcement event's `"relays"` tag. Patch and PR events SHOULD include an `a` tag pointing to that repository's announcement address.
Patches SHOULD be used if each event is under 60kb, otherwise PRs SHOULD be used.
### Patches
Patches in a patch set SHOULD include a [NIP-10](10.md) `e` `reply` tag pointing to the previous patch.
The first patch revision in a patch revision SHOULD include a [NIP-10](10.md) `e` `reply` to the original root patch.
```jsonc
{
"kind": 1617,
"content": "<patch>", // contents of <git format-patch>
"tags": [
["a", "30617:<base-repo-owner-pubkey>:<base-repo-id>"],
["r", "<earliest-unique-commit-id-of-repo>"] // so clients can subscribe to all patches sent to a local git repo
["p", "<repository-owner>"],
["p", "<other-user>"], // optionally send the patch to another user to bring it to their attention
["t", "root"], // omitted for additional patches in a series
// for the first patch in a revision
["t", "root-revision"],
// optional tags for when it is desirable that the merged patch has a stable commit id
// these fields are necessary for ensuring that the commit resulting from applying a patch
// has the same id as it had in the proposer's machine -- all these tags can be omitted
// if the maintainer doesn't care about these things
["commit", "<current-commit-id>"],
["r", "<current-commit-id>"] // so clients can find existing patches for a specific commit
["parent-commit", "<parent-commit-id>"],
["commit-pgp-sig", "-----BEGIN PGP SIGNATURE-----..."], // empty string for unsigned commit
["committer", "<name>", "<email>", "<timestamp>", "<timezone offset in minutes>"],
]
}
```
The first patch in a series MAY be a cover letter in the format produced by `git format-patch`.
### Pull Requests
The PR or PR update tip SHOULD be successfully pushed to `refs/nostr/<[PR|PR-Update]-event-id>` in all repositories listed in its `clone` tag before the event is signed.
An attempt SHOULD be made to push this ref to all repositories listed in the repository's announcement event's `"clone"` tag, for which their is reason to believe the user might have write access. This includes each [grasp server](https://njump.me/naddr1qvzqqqrhnypzpgqgmmc409hm4xsdd74sf68a2uyf9pwel4g9mfdg8l5244t6x4jdqy28wumn8ghj7un9d3shjtnwva5hgtnyv4mqqpt8wfshxuqlnvh8x) which can be identified using this method: `clone` tag includes `[http|https]://<grasp-path>/<valid-npub>/<string>.git` and `relays` tag includes `[ws/wss]://<grasp-path>`.
Clients MAY fallback to creating a 'personal-fork' `repository announcement` listing other grasp servers, e.g. from the `User grasp list`, for the purpose of serving the specified commit(s).
```jsonc
{
"kind": 1618,
"content": "<markdown text>",
"tags": [
["a", "30617:<base-repo-owner-pubkey>:<base-repo-id>"],
["r", "<earliest-unique-commit-id-of-repo>"] // so clients can subscribe to all PRs sent to a local git repo
["p", "<repository-owner>"],
["p", "<other-user>"], // optionally send the PR to another user to bring it to their attention
["subject", "<PR-subject>"],
["t", "<PR-label>"], // optional
["t", "<another-PR-label>"], // optional
["c", "<current-commit-id>"], // tip of the PR branch
["clone", "<clone-url>", ...], // at least one git clone url where commit can be downloaded
["branch-name", "<branch-name>"], // optional recommended branch name
["e", "<root-patch-event-id>"], // optionally indicate PR is a revision of an existing patch, which should be closed
["merge-base", "<commit-id>"], // optional: the most recent common ancestor with the target branch
]
}
```
### Pull Request Updates
A PR Update changes the tip of a referenced PR event.
```jsonc
{
"kind": 1619,
"content": "",
"tags": [
["a", "30617:<base-repo-owner-pubkey>:<base-repo-id>"],
["r", "<earliest-unique-commit-id-of-repo>"] // so clients can subscribe to all PRs sent to a local git repo
["p", "<repository-owner>"],
["p", "<other-user>"], // optionally send the PR to another user to bring it to their attention
// NIP-22 tags
["E", "<pull-request-event-id>"],
["P", "<pull-request-author>"],
["c", "<current-commit-id>"], // updated tip of PR
["clone", "<clone-url>", ...], // at least one git clone url where commit can be downloaded
["merge-base", "<commit-id>"], // optional: the most recent common ancestor with the target branch
]
}
```
## Issues
Issues are Markdown text that is just human-readable conversational threads related to the repository: bug reports, feature requests, questions or comments of any kind. Like patches, these SHOULD be sent to the relays specified in that repository's announcement event's `"relays"` tag.
Issues may have a `subject` tag, which clients can utilize to display a header. Additionally, one or more `t` tags may be included to provide labels for the issue.
```json
{
"kind": 1621,
"content": "<markdown text>",
"tags": [
["a", "30617:<base-repo-owner-pubkey>:<base-repo-id>"],
["p", "<repository-owner>"]
["subject", "<issue-subject>"]
["t", "<issue-label>"]
["t", "<another-issue-label>"]
]
}
```
## Replies
Replies to either a `kind:1621` (_issue_), `kind:1617` (_patch_) or `kind:1618` (_pull request_) event should follow [NIP-22 comment](22.md).
## Status
Root Patches, PRs and Issues have a Status that defaults to 'Open' and can be set by issuing Status events.
```jsonc
{
"kind": 1630, // Open
"kind": 1631, // Applied / Merged for Patches; Resolved for Issues
"kind": 1632, // Closed
"kind": 1633, // Draft
"content": "<markdown text>",
"tags": [
["e", "<issue-or-PR-or-original-root-patch-id-hex>", "", "root"],
["e", "<accepted-revision-root-id-hex>", "", "reply"], // for when revisions applied
["p", "<repository-owner>"],
["p", "<root-event-author>"],
["p", "<revision-author>"],
// optional for improved subscription filter efficiency
["a", "30617:<base-repo-owner-pubkey>:<base-repo-id>", "<relay-url>"],
["r", "<earliest-unique-commit-id-of-repo>"]
// optional for `1631` status
["q", "<applied-or-merged-patch-event-id>", "<relay-url>", "<pubkey>"], // for each
// when merged
["merge-commit", "<merge-commit-id>"]
["r", "<merge-commit-id>"]
// when applied
["applied-as-commits", "<commit-id-in-master-branch>", ...]
["r", "<applied-commit-id>"] // for each
]
}
```
The most recent Status event (by `created_at` date) from either the issue/patch author or a maintainer is considered valid.
The Status of a patch-revision is to either that of the root-patch, or `1632` (_Closed_) if the root-patch's Status is `1631` (_Applied/Merged_) and the patch-revision isn't tagged in the `1631` (_Applied/Merged_) event.
## User grasp list
List of [grasp servers](https://njump.me/naddr1qvzqqqrhnypzpgqgmmc409hm4xsdd74sf68a2uyf9pwel4g9mfdg8l5244t6x4jdqy28wumn8ghj7un9d3shjtnwva5hgtnyv4mqqpt8wfshxuqlnvh8x) the user generally wishes to use for NIP-34 related activity. It is similar in function to the NIP-65 relay list and NIP-B7 blossom list.
The event SHOULD include a list of `g` tags with grasp service websocket URLs in order of preference.
```jsonc
{
"kind": 10317,
"content": "",
"tags": [
["g", "<grasp-service-websocket-url>"], // zero or more grasp sever urls
]
}
```
## Possible things to be added later
- inline file comments kind (we probably need one for patches and a different one for merged files)

678
package-lock.json generated

@ -8,9 +8,18 @@ @@ -8,9 +8,18 @@
"name": "gitrepublic-web",
"version": "0.1.0",
"dependencies": {
"@codemirror/basic-setup": "^0.20.0",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.1",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.14",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"codemirror": "^6.0.2",
"codemirror-asciidoc": "^2.0.1",
"marked": "^17.0.2",
"nostr-tools": "^2.22.1",
"simple-git": "^3.31.1",
"svelte": "^5.0.0"
},
"devDependencies": {
@ -25,6 +34,433 @@ @@ -25,6 +34,433 @@
"vite": "^5.0.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "0.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-0.20.3.tgz",
"integrity": "sha512-lYB+NPGP+LEzAudkWhLfMxhTrxtLILGl938w+RcFrGdrIc54A+UgmCoz+McE3IYRFp4xyQcL4uFJwo+93YdgHw==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^0.20.0",
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/autocomplete/node_modules/@codemirror/language": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-0.20.2.tgz",
"integrity": "sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"@lezer/common": "^0.16.0",
"@lezer/highlight": "^0.16.0",
"@lezer/lr": "^0.16.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/autocomplete/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/autocomplete/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/autocomplete/node_modules/@lezer/highlight": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-0.16.0.tgz",
"integrity": "sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/autocomplete/node_modules/@lezer/lr": {
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-0.16.3.tgz",
"integrity": "sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/basic-setup": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/basic-setup/-/basic-setup-0.20.0.tgz",
"integrity": "sha512-W/ERKMLErWkrVLyP5I8Yh8PXl4r+WFNkdYVSzkXYPQv2RMPSkWpr2BgggiSJ8AHF/q3GuApncDD8I4BZz65fyg==",
"deprecated": "In version 6.0, this package has been renamed to just 'codemirror'",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^0.20.0",
"@codemirror/commands": "^0.20.0",
"@codemirror/language": "^0.20.0",
"@codemirror/lint": "^0.20.0",
"@codemirror/search": "^0.20.0",
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0"
}
},
"node_modules/@codemirror/basic-setup/node_modules/@codemirror/language": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-0.20.2.tgz",
"integrity": "sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"@lezer/common": "^0.16.0",
"@lezer/highlight": "^0.16.0",
"@lezer/lr": "^0.16.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/basic-setup/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/basic-setup/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/basic-setup/node_modules/@lezer/highlight": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-0.16.0.tgz",
"integrity": "sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/basic-setup/node_modules/@lezer/lr": {
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-0.16.3.tgz",
"integrity": "sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/commands": {
"version": "0.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-0.20.0.tgz",
"integrity": "sha512-v9L5NNVA+A9R6zaFvaTbxs30kc69F6BkOoiEbeFw4m4I0exmDEKBILN6mK+GksJtvTzGBxvhAPlVFTdQW8GB7Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^0.20.0",
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/commands/node_modules/@codemirror/language": {
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-0.20.2.tgz",
"integrity": "sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"@lezer/common": "^0.16.0",
"@lezer/highlight": "^0.16.0",
"@lezer/lr": "^0.16.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/commands/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/commands/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/commands/node_modules/@lezer/highlight": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-0.16.0.tgz",
"integrity": "sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/commands/node_modules/@lezer/lr": {
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-0.16.3.tgz",
"integrity": "sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^0.16.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-css/node_modules/@codemirror/autocomplete": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/lang-css/node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-html/node_modules/@codemirror/autocomplete": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/lang-html/node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-javascript/node_modules/@codemirror/autocomplete": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/lang-javascript/node_modules/@codemirror/lint": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.4.tgz",
"integrity": "sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/lang-javascript/node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown/node_modules/@codemirror/autocomplete": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown/node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@codemirror/language": {
"version": "6.12.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
"integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/language/node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@codemirror/lint": {
"version": "0.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-0.20.3.tgz",
"integrity": "sha512-06xUScbbspZ8mKoODQCEx6hz1bjaq9m8W8DxdycWARMiiX1wMtfCh/MoHpaL7ws/KUMwlsFFfp2qhm32oaCvVA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.2",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/lint/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/lint/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/search": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-0.20.1.tgz",
"integrity": "sha512-ROe6gRboQU5E4z6GAkNa2kxhXqsGNbeLEisbvzbOeB7nuDYXUZ70vGIgmqPu0tB+1M3F9yWk6W8k2vrFpJaD4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"@codemirror/view": "^0.20.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search/node_modules/@codemirror/state": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz",
"integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==",
"license": "MIT"
},
"node_modules/@codemirror/search/node_modules/@codemirror/view": {
"version": "0.20.7",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz",
"integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.20.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.4",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz",
"integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.39.14",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.14.tgz",
"integrity": "sha512-WJcvgHm/6Q7dvGT0YFv/6PSkoc36QlR0VCESS6x9tGsnF1lWLmmYxOgX3HH6v8fo6AvSLgpcs+H0Olre6MKXlg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@ -587,6 +1023,130 @@ @@ -587,6 +1023,130 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kwsites/file-exists": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
"integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
"license": "MIT",
"dependencies": {
"debug": "^4.1.1"
}
},
"node_modules/@kwsites/promise-deferred": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
"license": "MIT"
},
"node_modules/@lezer/common": {
"version": "0.16.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-0.16.1.tgz",
"integrity": "sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz",
"integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/css/node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/highlight/node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/html/node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/javascript/node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@lezer/lr": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/lr/node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@lezer/markdown": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz",
"integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lezer/markdown/node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
@ -1753,6 +2313,79 @@ @@ -1753,6 +2313,79 @@
"node": ">=6"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/codemirror-asciidoc": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/codemirror-asciidoc/-/codemirror-asciidoc-2.0.1.tgz",
"integrity": "sha512-h6Xhj+ZsWh/DTNE3xMfRv9edufchsVVwPED7wSGMeEdoYk/UtCZmwRGH0ZZQkr43aNVF3tWGLZJGT+cAeYgUIg==",
"license": "BSD"
},
"node_modules/codemirror/node_modules/@codemirror/autocomplete": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/codemirror/node_modules/@codemirror/commands": {
"version": "6.10.2",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.2.tgz",
"integrity": "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/codemirror/node_modules/@codemirror/lint": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.4.tgz",
"integrity": "sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/codemirror/node_modules/@codemirror/search": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/codemirror/node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -1796,6 +2429,12 @@ @@ -1796,6 +2429,12 @@
"node": ">= 0.6"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -2706,6 +3345,18 @@ @@ -2706,6 +3345,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.2.tgz",
"integrity": "sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -3338,6 +3989,21 @@ @@ -3338,6 +3989,21 @@
"node": ">=8"
}
},
"node_modules/simple-git": {
"version": "3.31.1",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.31.1.tgz",
"integrity": "sha512-oiWP4Q9+kO8q9hHqkX35uuHmxiEbZNTrZ5IPxgMGrJwN76pzjm/jabkZO0ItEcqxAincqGAzL3QHSaHt4+knBg==",
"license": "MIT",
"dependencies": {
"@kwsites/file-exists": "^1.1.1",
"@kwsites/promise-deferred": "^1.1.1",
"debug": "^4.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/steveukx/git-js?sponsor=1"
}
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@ -3426,6 +4092,12 @@ @@ -3426,6 +4092,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@ -3749,6 +4421,12 @@ @@ -3749,6 +4421,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

9
package.json

@ -12,9 +12,18 @@ @@ -12,9 +12,18 @@
"format": "prettier --write ."
},
"dependencies": {
"@codemirror/basic-setup": "^0.20.0",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.1",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.14",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"codemirror": "^6.0.2",
"codemirror-asciidoc": "^2.0.1",
"marked": "^17.0.2",
"nostr-tools": "^2.22.1",
"simple-git": "^3.31.1",
"svelte": "^5.0.0"
},
"devDependencies": {

5
src/hooks.server.ts

@ -5,17 +5,16 @@ @@ -5,17 +5,16 @@
import type { Handle } from '@sveltejs/kit';
import { RepoPollingService } from './lib/services/nostr/repo-polling.js';
import { GIT_DOMAIN } from './lib/config.js';
import { GIT_DOMAIN, DEFAULT_NOSTR_RELAYS } from './lib/config.js';
// Initialize polling service
const relays = (process.env.NOSTR_RELAYS || 'wss://theforest.nostr1.com,wss://nostr.land,wss://relay.damus.io').split(',');
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const domain = GIT_DOMAIN;
let pollingService: RepoPollingService | null = null;
if (typeof process !== 'undefined') {
pollingService = new RepoPollingService(relays, repoRoot, domain);
pollingService = new RepoPollingService(DEFAULT_NOSTR_RELAYS, repoRoot, domain);
pollingService.start();
console.log('Started repo polling service');
}

115
src/lib/components/CodeEditor.svelte

@ -0,0 +1,115 @@ @@ -0,0 +1,115 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { EditorView } from '@codemirror/view';
import { EditorState, type Extension } from '@codemirror/state';
import { basicSetup } from '@codemirror/basic-setup';
import { markdown } from '@codemirror/lang-markdown';
import { StreamLanguage } from '@codemirror/language';
import { asciidoc } from 'codemirror-asciidoc';
interface Props {
content?: string;
language?: 'markdown' | 'asciidoc' | 'text';
onChange?: (value: string) => void;
}
let {
content = $bindable(''),
language = $bindable('text'),
onChange = () => {}
}: Props = $props();
let editorView: EditorView | null = null;
let editorElement: HTMLDivElement;
function getLanguageExtension(): Extension[] {
switch (language) {
case 'markdown':
return [markdown()];
case 'asciidoc':
return [StreamLanguage.define(asciidoc)];
default:
return [];
}
}
onMount(() => {
const state = EditorState.create({
doc: content,
extensions: [
basicSetup,
...getLanguageExtension(),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const newContent = update.state.doc.toString();
onChange(newContent);
}
})
]
});
editorView = new EditorView({
state,
parent: editorElement
});
return () => {
editorView?.destroy();
};
});
onDestroy(() => {
editorView?.destroy();
});
// Update content when prop changes externally
$effect(() => {
if (editorView && content !== editorView.state.doc.toString()) {
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: content
}
});
}
});
// Update language when prop changes
$effect(() => {
if (editorView) {
const state = EditorState.create({
doc: editorView.state.doc.toString(),
extensions: [
basicSetup,
...getLanguageExtension(),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const newContent = update.state.doc.toString();
onChange(newContent);
}
})
]
});
editorView.setState(state);
}
});
</script>
<div bind:this={editorElement} class="code-editor"></div>
<style>
.code-editor {
height: 100%;
width: 100%;
overflow: auto;
}
:global(.code-editor .cm-editor) {
height: 100%;
}
:global(.code-editor .cm-scroller) {
overflow: auto;
}
</style>

24
src/lib/config.ts

@ -12,6 +12,19 @@ export const GIT_DOMAIN = @@ -12,6 +12,19 @@ export const GIT_DOMAIN =
? process.env.GIT_DOMAIN
: 'localhost:6543';
/**
* Default Nostr relays to use
* Can be overridden by NOSTR_RELAYS env var (comma-separated list)
*/
export const DEFAULT_NOSTR_RELAYS =
typeof process !== 'undefined' && process.env?.NOSTR_RELAYS
? process.env.NOSTR_RELAYS.split(',').map(r => r.trim()).filter(r => r.length > 0)
: [
'wss://theforest.nostr1.com',
'wss://nostr.land',
'wss://relay.damus.io'
];
/**
* Get the full git URL for a repository
*/
@ -19,3 +32,14 @@ export function getGitUrl(npub: string, repoName: string): string { @@ -19,3 +32,14 @@ export function getGitUrl(npub: string, repoName: string): string {
const protocol = GIT_DOMAIN.startsWith('localhost') ? 'http' : 'https';
return `${protocol}://${GIT_DOMAIN}/${npub}/${repoName}.git`;
}
/**
* Combine default relays with user's relays (from kind 10002)
* Returns a deduplicated list with user relays first, then defaults
*/
export function combineRelays(userRelays: string[] = [], defaultRelays: string[] = DEFAULT_NOSTR_RELAYS): string[] {
// Combine user relays with defaults, removing duplicates
// User relays come first to prioritize them
const combined = [...userRelays, ...defaultRelays];
return [...new Set(combined)];
}

590
src/lib/services/git/file-manager.ts

@ -0,0 +1,590 @@ @@ -0,0 +1,590 @@
/**
* File manager for git repositories
* Handles reading, writing, and listing files in git repos
*/
import simpleGit, { type SimpleGit } from 'simple-git';
import { readFile, readdir, stat } from 'fs/promises';
import { join, dirname } from 'path';
import { existsSync } from 'fs';
import { RepoManager } from './repo-manager.js';
export interface FileEntry {
name: string;
path: string;
type: 'file' | 'directory';
size?: number;
}
export interface FileContent {
content: string;
encoding: string;
size: number;
}
export interface Commit {
hash: string;
message: string;
author: string;
date: string;
files: string[];
}
export interface Diff {
file: string;
additions: number;
deletions: number;
diff: string;
}
export interface Tag {
name: string;
hash: string;
message?: string;
}
export class FileManager {
private repoManager: RepoManager;
private repoRoot: string;
constructor(repoRoot: string = '/repos') {
this.repoRoot = repoRoot;
this.repoManager = new RepoManager(repoRoot);
}
/**
* Get the full path to a repository
*/
private getRepoPath(npub: string, repoName: string): string {
return join(this.repoRoot, npub, `${repoName}.git`);
}
/**
* Check if repository exists
*/
repoExists(npub: string, repoName: string): boolean {
const repoPath = this.getRepoPath(npub, repoName);
return this.repoManager.repoExists(repoPath);
}
/**
* List files and directories in a repository at a given path
*/
async listFiles(npub: string, repoName: string, ref: string = 'HEAD', path: string = ''): Promise<FileEntry[]> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
const git: SimpleGit = simpleGit(repoPath);
try {
// Get the tree for the specified path
const tree = await git.raw(['ls-tree', '-l', ref, path || '.']);
if (!tree) {
return [];
}
const entries: FileEntry[] = [];
const lines = tree.trim().split('\n').filter(line => line.length > 0);
for (const line of lines) {
// Format: <mode> <type> <object> <size>\t<file>
const match = line.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)\s+(.+)$/);
if (match) {
const [, , type, , size, name] = match;
const fullPath = path ? join(path, name) : name;
entries.push({
name,
path: fullPath,
type: type === 'tree' ? 'directory' : 'file',
size: size !== '-' ? parseInt(size, 10) : undefined
});
}
}
return entries.sort((a, b) => {
// Directories first, then files, both alphabetically
if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
} catch (error) {
console.error('Error listing files:', error);
throw new Error(`Failed to list files: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get file content from a repository
*/
async getFileContent(npub: string, repoName: string, filePath: string, ref: string = 'HEAD'): Promise<FileContent> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
const git: SimpleGit = simpleGit(repoPath);
try {
// Get file content using git show
const content = await git.show([`${ref}:${filePath}`]);
// Try to determine encoding (assume UTF-8 for text files)
const encoding = 'utf-8';
const size = Buffer.byteLength(content, encoding);
return {
content,
encoding,
size
};
} catch (error) {
console.error('Error reading file:', error);
throw new Error(`Failed to read file: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Write file and commit changes
*/
async writeFile(
npub: string,
repoName: string,
filePath: string,
content: string,
commitMessage: string,
authorName: string,
authorEmail: string,
branch: string = 'main'
): Promise<void> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
try {
// Clone bare repo to a temporary working directory (non-bare)
const workDir = join(this.repoRoot, npub, `${repoName}.work`);
const { rm } = await import('fs/promises');
// Remove work directory if it exists to ensure clean state
if (existsSync(workDir)) {
await rm(workDir, { recursive: true, force: true });
}
// Clone the bare repo to a working directory
const git: SimpleGit = simpleGit();
await git.clone(repoPath, workDir);
// Use the work directory for operations
const workGit: SimpleGit = simpleGit(workDir);
// Checkout the branch (or create it)
try {
await workGit.checkout([branch]);
} catch {
// Branch doesn't exist, create it
await workGit.checkout(['-b', branch]);
}
// Write the file
const fullFilePath = join(workDir, filePath);
const fileDir = dirname(fullFilePath);
// Ensure directory exists
if (!existsSync(fileDir)) {
const { mkdir } = await import('fs/promises');
await mkdir(fileDir, { recursive: true });
}
const { writeFile: writeFileFs } = await import('fs/promises');
await writeFileFs(fullFilePath, content, 'utf-8');
// Stage the file
await workGit.add(filePath);
// Commit
await workGit.commit(commitMessage, [filePath], {
'--author': `${authorName} <${authorEmail}>`
});
// Push to bare repo
await workGit.push(['origin', branch]);
// Clean up work directory
await rm(workDir, { recursive: true, force: true });
} catch (error) {
console.error('Error writing file:', error);
throw new Error(`Failed to write file: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get list of branches
*/
async getBranches(npub: string, repoName: string): Promise<string[]> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
const git: SimpleGit = simpleGit(repoPath);
try {
const branches = await git.branch(['-r']);
return branches.all
.map(b => b.replace(/^origin\//, ''))
.filter(b => !b.includes('HEAD'));
} catch (error) {
console.error('Error getting branches:', error);
return ['main', 'master']; // Default branches
}
}
/**
* Create a new file
*/
async createFile(
npub: string,
repoName: string,
filePath: string,
content: string,
commitMessage: string,
authorName: string,
authorEmail: string,
branch: string = 'main'
): Promise<void> {
// Reuse writeFile logic - it will create the file if it doesn't exist
return this.writeFile(npub, repoName, filePath, content, commitMessage, authorName, authorEmail, branch);
}
/**
* Delete a file
*/
async deleteFile(
npub: string,
repoName: string,
filePath: string,
commitMessage: string,
authorName: string,
authorEmail: string,
branch: string = 'main'
): Promise<void> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
try {
const workDir = join(this.repoRoot, npub, `${repoName}.work`);
const { rm } = await import('fs/promises');
if (existsSync(workDir)) {
await rm(workDir, { recursive: true, force: true });
}
const git: SimpleGit = simpleGit();
await git.clone(repoPath, workDir);
const workGit: SimpleGit = simpleGit(workDir);
try {
await workGit.checkout([branch]);
} catch {
await workGit.checkout(['-b', branch]);
}
// Remove the file
const fullFilePath = join(workDir, filePath);
if (existsSync(fullFilePath)) {
const { unlink } = await import('fs/promises');
await unlink(fullFilePath);
}
// Stage the deletion
await workGit.rm([filePath]);
// Commit
await workGit.commit(commitMessage, [filePath], {
'--author': `${authorName} <${authorEmail}>`
});
// Push to bare repo
await workGit.push(['origin', branch]);
// Clean up
await rm(workDir, { recursive: true, force: true });
} catch (error) {
console.error('Error deleting file:', error);
throw new Error(`Failed to delete file: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Create a new branch
*/
async createBranch(
npub: string,
repoName: string,
branchName: string,
fromBranch: string = 'main'
): Promise<void> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
try {
const workDir = join(this.repoRoot, npub, `${repoName}.work`);
const { rm } = await import('fs/promises');
if (existsSync(workDir)) {
await rm(workDir, { recursive: true, force: true });
}
const git: SimpleGit = simpleGit();
await git.clone(repoPath, workDir);
const workGit: SimpleGit = simpleGit(workDir);
// Checkout source branch
await workGit.checkout([fromBranch]);
// Create and checkout new branch
await workGit.checkout(['-b', branchName]);
// Push new branch
await workGit.push(['origin', branchName]);
// Clean up
await rm(workDir, { recursive: true, force: true });
} catch (error) {
console.error('Error creating branch:', error);
throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get commit history
*/
async getCommitHistory(
npub: string,
repoName: string,
branch: string = 'main',
limit: number = 50,
path?: string
): Promise<Commit[]> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
const git: SimpleGit = simpleGit(repoPath);
try {
const logOptions: any = {
maxCount: limit,
from: branch
};
if (path) {
logOptions.file = path;
}
const log = await git.log(logOptions);
return log.all.map(commit => ({
hash: commit.hash,
message: commit.message,
author: `${commit.author_name} <${commit.author_email}>`,
date: commit.date,
files: commit.diff?.files?.map((f: any) => f.file) || []
}));
} catch (error) {
console.error('Error getting commit history:', error);
throw new Error(`Failed to get commit history: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get diff between two commits or for a file
*/
async getDiff(
npub: string,
repoName: string,
fromRef: string,
toRef: string = 'HEAD',
filePath?: string
): Promise<Diff[]> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
const git: SimpleGit = simpleGit(repoPath);
try {
const diffOptions: string[] = [fromRef, toRef];
if (filePath) {
diffOptions.push('--', filePath);
}
const diff = await git.diff(diffOptions);
const stats = await git.diffSummary(diffOptions);
// Parse diff output
const files: Diff[] = [];
const diffLines = diff.split('\n');
let currentFile = '';
let currentDiff = '';
let inFileHeader = false;
for (const line of diffLines) {
if (line.startsWith('diff --git')) {
if (currentFile) {
files.push({
file: currentFile,
additions: 0,
deletions: 0,
diff: currentDiff
});
}
const match = line.match(/diff --git a\/(.+?) b\/(.+?)$/);
if (match) {
currentFile = match[2];
currentDiff = line + '\n';
inFileHeader = true;
}
} else {
currentDiff += line + '\n';
if (line.startsWith('@@')) {
inFileHeader = false;
}
if (!inFileHeader && (line.startsWith('+') || line.startsWith('-'))) {
// Count additions/deletions
}
}
}
if (currentFile) {
files.push({
file: currentFile,
additions: 0,
deletions: 0,
diff: currentDiff
});
}
// Add stats from diffSummary
if (stats.files && files.length > 0) {
for (const statFile of stats.files) {
const file = files.find(f => f.file === statFile.file);
if (file) {
file.additions = statFile.insertions;
file.deletions = statFile.deletions;
}
}
}
return files;
} catch (error) {
console.error('Error getting diff:', error);
throw new Error(`Failed to get diff: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Create a tag
*/
async createTag(
npub: string,
repoName: string,
tagName: string,
ref: string = 'HEAD',
message?: string,
authorName?: string,
authorEmail?: string
): Promise<void> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
const git: SimpleGit = simpleGit(repoPath);
try {
if (message) {
// Create annotated tag
const tagOptions: string[] = ['-a', tagName, '-m', message];
if (ref !== 'HEAD') {
tagOptions.push(ref);
}
await git.addTag(tagName, message);
} else {
// Create lightweight tag
await git.addTag(tagName);
}
} catch (error) {
console.error('Error creating tag:', error);
throw new Error(`Failed to create tag: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Get list of tags
*/
async getTags(npub: string, repoName: string): Promise<Tag[]> {
const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) {
throw new Error('Repository not found');
}
const git: SimpleGit = simpleGit(repoPath);
try {
const tags = await git.tags();
const tagList: Tag[] = [];
for (const tagName of tags.all) {
try {
// Try to get tag message
const tagInfo = await git.raw(['cat-file', '-p', tagName]);
const messageMatch = tagInfo.match(/^(.+)$/m);
const hash = await git.raw(['rev-parse', tagName]);
tagList.push({
name: tagName,
hash: hash.trim(),
message: messageMatch ? messageMatch[1] : undefined
});
} catch {
// Lightweight tag
const hash = await git.raw(['rev-parse', tagName]);
tagList.push({
name: tagName,
hash: hash.trim()
});
}
}
return tagList;
} catch (error) {
console.error('Error getting tags:', error);
return [];
}
}
}

73
src/lib/services/git/repo-manager.ts

@ -5,10 +5,12 @@ @@ -5,10 +5,12 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import { existsSync, mkdirSync } from 'fs';
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import type { NostrEvent } from '../../types/nostr.js';
import { GIT_DOMAIN } from '../../config.js';
import { generateVerificationFile, VERIFICATION_FILE_PATH } from '../nostr/repo-verification.js';
import simpleGit, { type SimpleGit } from 'simple-git';
const execAsync = promisify(exec);
@ -66,8 +68,12 @@ export class RepoManager { @@ -66,8 +68,12 @@ export class RepoManager {
}
// Create bare repository if it doesn't exist
if (!existsSync(repoPath.fullPath)) {
const isNewRepo = !existsSync(repoPath.fullPath);
if (isNewRepo) {
await execAsync(`git init --bare "${repoPath.fullPath}"`);
// Create verification file in the repository
await this.createVerificationFile(repoPath.fullPath, event);
}
// If there are other clone URLs, sync from them
@ -142,4 +148,67 @@ export class RepoManager { @@ -142,4 +148,67 @@ export class RepoManager {
repoExists(repoPath: string): boolean {
return existsSync(repoPath);
}
/**
* Create verification file in a new repository
* This proves the repository is owned by the announcement author
*/
private async createVerificationFile(repoPath: string, event: NostrEvent): Promise<void> {
try {
// Create a temporary working directory
const repoName = this.parseRepoPathForName(repoPath)?.repoName || 'temp';
const workDir = join(repoPath, '..', `${repoName}.work`);
const { rm, mkdir } = await import('fs/promises');
// Clean up if exists
if (existsSync(workDir)) {
await rm(workDir, { recursive: true, force: true });
}
await mkdir(workDir, { recursive: true });
// Clone the bare repo
const git: SimpleGit = simpleGit();
await git.clone(repoPath, workDir);
// Generate verification file content
const verificationContent = generateVerificationFile(event, event.pubkey);
// Write verification file
const verificationPath = join(workDir, VERIFICATION_FILE_PATH);
writeFileSync(verificationPath, verificationContent, 'utf-8');
// Commit the verification file
const workGit: SimpleGit = simpleGit(workDir);
await workGit.add(VERIFICATION_FILE_PATH);
// Use the event timestamp for commit date
const commitDate = new Date(event.created_at * 1000).toISOString();
await workGit.commit('Add Nostr repository verification file', [VERIFICATION_FILE_PATH], {
'--author': `Nostr <${event.pubkey}@nostr>`,
'--date': commitDate
});
// Push back to bare repo
await workGit.push(['origin', 'main']).catch(async () => {
// If main branch doesn't exist, create it
await workGit.checkout(['-b', 'main']);
await workGit.push(['origin', 'main']);
});
// Clean up
await rm(workDir, { recursive: true, force: true });
} catch (error) {
console.error('Failed to create verification file:', error);
// Don't throw - verification file creation is important but shouldn't block provisioning
}
}
/**
* Parse repo path to extract repo name (helper for verification file creation)
*/
private parseRepoPathForName(repoPath: string): { repoName: string } | null {
const match = repoPath.match(/\/([^\/]+)\.git$/);
if (!match) return null;
return { repoName: match[1] };
}
}

190
src/lib/services/nostr/issues-service.ts

@ -0,0 +1,190 @@ @@ -0,0 +1,190 @@
/**
* Service for managing NIP-34 Issues (kind 1621)
*/
import { NostrClient } from './nostr-client.js';
import { KIND } from '../../types/nostr.js';
import type { Issue, NostrEvent, StatusEvent } from '../../types/nostr.js';
import { signEventWithNIP07 } from './nip07-signer.js';
export interface IssueWithStatus extends Issue {
status: 'open' | 'closed' | 'resolved' | 'draft';
statusEvent?: StatusEvent;
}
export class IssuesService {
private nostrClient: NostrClient;
private relays: string[];
constructor(relays: string[] = []) {
this.relays = relays;
this.nostrClient = new NostrClient(relays);
}
/**
* Get repository announcement address (a tag format)
*/
private getRepoAddress(repoOwnerPubkey: string, repoId: string): string {
return `30617:${repoOwnerPubkey}:${repoId}`;
}
/**
* Extract repo address from event tags
*/
private extractRepoAddress(event: NostrEvent): { owner: string; id: string } | null {
const aTag = event.tags.find(t => t[0] === 'a');
if (!aTag || !aTag[1]) return null;
const parts = aTag[1].split(':');
if (parts.length !== 3 || parts[0] !== '30617') return null;
return { owner: parts[1], id: parts[2] };
}
/**
* Fetch issues for a repository
*/
async getIssues(repoOwnerPubkey: string, repoId: string): Promise<IssueWithStatus[]> {
const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId);
const issues = await this.nostrClient.fetchEvents([
{
kinds: [KIND.ISSUE],
'#a': [repoAddress],
limit: 100
}
]) as Issue[];
// Fetch status events for each issue
const issueIds = issues.map(i => i.id);
const statusEvents = await this.nostrClient.fetchEvents([
{
kinds: [KIND.STATUS_OPEN, KIND.STATUS_APPLIED, KIND.STATUS_CLOSED, KIND.STATUS_DRAFT],
'#e': issueIds,
limit: 1000
}
]) as StatusEvent[];
// Group status events by issue ID and get the most recent one
const statusMap = new Map<string, StatusEvent>();
for (const status of statusEvents) {
const rootTag = status.tags.find(t => t[0] === 'e' && t[3] === 'root');
if (rootTag && rootTag[1]) {
const issueId = rootTag[1];
const existing = statusMap.get(issueId);
if (!existing || status.created_at > existing.created_at) {
statusMap.set(issueId, status);
}
}
}
// Combine issues with their status
return issues.map(issue => {
const statusEvent = statusMap.get(issue.id);
let status: 'open' | 'closed' | 'resolved' | 'draft' = 'open';
if (statusEvent) {
if (statusEvent.kind === KIND.STATUS_OPEN) status = 'open';
else if (statusEvent.kind === KIND.STATUS_APPLIED) status = 'resolved';
else if (statusEvent.kind === KIND.STATUS_CLOSED) status = 'closed';
else if (statusEvent.kind === KIND.STATUS_DRAFT) status = 'draft';
}
return {
...issue,
status,
statusEvent
};
});
}
/**
* Create a new issue
*/
async createIssue(
repoOwnerPubkey: string,
repoId: string,
subject: string,
content: string,
labels: string[] = []
): Promise<Issue> {
const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId);
const tags: string[][] = [
['a', repoAddress],
['p', repoOwnerPubkey],
['subject', subject]
];
// Add labels
for (const label of labels) {
tags.push(['t', label]);
}
const event = await signEventWithNIP07({
kind: KIND.ISSUE,
content,
tags,
created_at: Math.floor(Date.now() / 1000),
pubkey: '' // Will be filled by signer
});
const result = await this.nostrClient.publishEvent(event, this.relays);
if (result.failed.length > 0 && result.success.length === 0) {
throw new Error('Failed to publish issue to all relays');
}
return event as Issue;
}
/**
* Update issue status
*/
async updateIssueStatus(
issueId: string,
issueAuthor: string,
repoOwnerPubkey: string,
repoId: string,
status: 'open' | 'closed' | 'resolved' | 'draft'
): Promise<StatusEvent> {
const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId);
let kind: number;
switch (status) {
case 'open':
kind = KIND.STATUS_OPEN;
break;
case 'resolved':
kind = KIND.STATUS_APPLIED;
break;
case 'closed':
kind = KIND.STATUS_CLOSED;
break;
case 'draft':
kind = KIND.STATUS_DRAFT;
break;
}
const tags: string[][] = [
['e', issueId, '', 'root'],
['p', repoOwnerPubkey],
['p', issueAuthor],
['a', repoAddress]
];
const event = await signEventWithNIP07({
kind,
content: `Issue ${status}`,
tags,
created_at: Math.floor(Date.now() / 1000),
pubkey: ''
});
const result = await this.nostrClient.publishEvent(event, this.relays);
if (result.failed.length > 0 && result.success.length === 0) {
throw new Error('Failed to publish status update to all relays');
}
return event as StatusEvent;
}
}

100
src/lib/services/nostr/maintainer-service.ts

@ -0,0 +1,100 @@ @@ -0,0 +1,100 @@
/**
* Service for checking repository maintainer permissions
* Based on NIP-34 repository announcements
*/
import { NostrClient } from './nostr-client.js';
import { KIND } from '../../types/nostr.js';
import type { NostrEvent } from '../../types/nostr.js';
import { nip19 } from 'nostr-tools';
export class MaintainerService {
private nostrClient: NostrClient;
private cache: Map<string, { maintainers: string[]; owner: string; timestamp: number }> = new Map();
private cacheTTL = 5 * 60 * 1000; // 5 minutes
constructor(relays: string[]) {
this.nostrClient = new NostrClient(relays);
}
/**
* Get maintainers for a repository from NIP-34 announcement
*/
async getMaintainers(repoOwnerPubkey: string, repoId: string): Promise<{ owner: string; maintainers: string[] }> {
const cacheKey = `${repoOwnerPubkey}:${repoId}`;
const cached = this.cache.get(cacheKey);
// Return cached if still valid
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
return { owner: cached.owner, maintainers: cached.maintainers };
}
try {
// Fetch the repository announcement
const events = await this.nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [repoOwnerPubkey],
'#d': [repoId],
limit: 1
}
]);
if (events.length === 0) {
// If no announcement found, only the owner is a maintainer
const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey] };
this.cache.set(cacheKey, { ...result, timestamp: Date.now() });
return result;
}
const announcement = events[0];
const maintainers: string[] = [announcement.pubkey]; // Owner is always a maintainer
// Extract maintainers from tags
for (const tag of announcement.tags) {
if (tag[0] === 'maintainers' && tag[1]) {
// Maintainers can be npub or hex pubkey
let pubkey = tag[1];
try {
// Try to decode if it's an npub
const decoded = nip19.decode(pubkey);
if (decoded.type === 'npub') {
pubkey = decoded.data as string;
}
} catch {
// Assume it's already a hex pubkey
}
if (pubkey && !maintainers.includes(pubkey)) {
maintainers.push(pubkey);
}
}
}
const result = { owner: announcement.pubkey, maintainers };
this.cache.set(cacheKey, { ...result, timestamp: Date.now() });
return result;
} catch (error) {
console.error('Error fetching maintainers:', error);
// Fallback: only owner is maintainer
const result = { owner: repoOwnerPubkey, maintainers: [repoOwnerPubkey] };
this.cache.set(cacheKey, { ...result, timestamp: Date.now() });
return result;
}
}
/**
* Check if a user is a maintainer of a repository
*/
async isMaintainer(userPubkey: string, repoOwnerPubkey: string, repoId: string): Promise<boolean> {
const { maintainers } = await this.getMaintainers(repoOwnerPubkey, repoId);
return maintainers.includes(userPubkey);
}
/**
* Clear cache for a repository (useful after maintainer changes)
*/
clearCache(repoOwnerPubkey: string, repoId: string): void {
const cacheKey = `${repoOwnerPubkey}:${repoId}`;
this.cache.delete(cacheKey);
}
}

192
src/lib/services/nostr/prs-service.ts

@ -0,0 +1,192 @@ @@ -0,0 +1,192 @@
/**
* Service for managing NIP-34 Pull Requests (kind 1618)
*/
import { NostrClient } from './nostr-client.js';
import { KIND } from '../../types/nostr.js';
import type { PullRequest, NostrEvent, StatusEvent } from '../../types/nostr.js';
import { signEventWithNIP07 } from './nip07-signer.js';
export interface PRWithStatus extends PullRequest {
status: 'open' | 'merged' | 'closed' | 'draft';
statusEvent?: StatusEvent;
}
export class PRsService {
private nostrClient: NostrClient;
private relays: string[];
constructor(relays: string[] = []) {
this.relays = relays;
this.nostrClient = new NostrClient(relays);
}
/**
* Get repository announcement address (a tag format)
*/
private getRepoAddress(repoOwnerPubkey: string, repoId: string): string {
return `30617:${repoOwnerPubkey}:${repoId}`;
}
/**
* Fetch pull requests for a repository
*/
async getPullRequests(repoOwnerPubkey: string, repoId: string): Promise<PRWithStatus[]> {
const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId);
const prs = await this.nostrClient.fetchEvents([
{
kinds: [KIND.PULL_REQUEST],
'#a': [repoAddress],
limit: 100
}
]) as PullRequest[];
// Fetch status events for each PR
const prIds = prs.map(pr => pr.id);
const statusEvents = await this.nostrClient.fetchEvents([
{
kinds: [KIND.STATUS_OPEN, KIND.STATUS_APPLIED, KIND.STATUS_CLOSED, KIND.STATUS_DRAFT],
'#e': prIds,
limit: 1000
}
]) as StatusEvent[];
// Group status events by PR ID and get the most recent one
const statusMap = new Map<string, StatusEvent>();
for (const status of statusEvents) {
const rootTag = status.tags.find(t => t[0] === 'e' && t[3] === 'root');
if (rootTag && rootTag[1]) {
const prId = rootTag[1];
const existing = statusMap.get(prId);
if (!existing || status.created_at > existing.created_at) {
statusMap.set(prId, status);
}
}
}
// Combine PRs with their status
return prs.map(pr => {
const statusEvent = statusMap.get(pr.id);
let status: 'open' | 'merged' | 'closed' | 'draft' = 'open';
if (statusEvent) {
if (statusEvent.kind === KIND.STATUS_OPEN) status = 'open';
else if (statusEvent.kind === KIND.STATUS_APPLIED) status = 'merged';
else if (statusEvent.kind === KIND.STATUS_CLOSED) status = 'closed';
else if (statusEvent.kind === KIND.STATUS_DRAFT) status = 'draft';
}
return {
...pr,
status,
statusEvent
};
});
}
/**
* Create a new pull request
*/
async createPullRequest(
repoOwnerPubkey: string,
repoId: string,
subject: string,
content: string,
commitId: string,
cloneUrl: string,
branchName?: string,
labels: string[] = []
): Promise<PullRequest> {
const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId);
const tags: string[][] = [
['a', repoAddress],
['p', repoOwnerPubkey],
['subject', subject],
['c', commitId],
['clone', cloneUrl]
];
if (branchName) {
tags.push(['branch-name', branchName]);
}
// Add labels
for (const label of labels) {
tags.push(['t', label]);
}
const event = await signEventWithNIP07({
kind: KIND.PULL_REQUEST,
content,
tags,
created_at: Math.floor(Date.now() / 1000),
pubkey: ''
});
const result = await this.nostrClient.publishEvent(event, this.relays);
if (result.failed.length > 0 && result.success.length === 0) {
throw new Error('Failed to publish pull request to all relays');
}
return event as PullRequest;
}
/**
* Update PR status
*/
async updatePRStatus(
prId: string,
prAuthor: string,
repoOwnerPubkey: string,
repoId: string,
status: 'open' | 'merged' | 'closed' | 'draft',
mergeCommitId?: string
): Promise<StatusEvent> {
const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId);
let kind: number;
switch (status) {
case 'open':
kind = KIND.STATUS_OPEN;
break;
case 'merged':
kind = KIND.STATUS_APPLIED;
break;
case 'closed':
kind = KIND.STATUS_CLOSED;
break;
case 'draft':
kind = KIND.STATUS_DRAFT;
break;
}
const tags: string[][] = [
['e', prId, '', 'root'],
['p', repoOwnerPubkey],
['p', prAuthor],
['a', repoAddress]
];
if (status === 'merged' && mergeCommitId) {
tags.push(['merge-commit', mergeCommitId]);
tags.push(['r', mergeCommitId]);
}
const event = await signEventWithNIP07({
kind,
content: `Pull request ${status}`,
tags,
created_at: Math.floor(Date.now() / 1000),
pubkey: ''
});
const result = await this.nostrClient.publishEvent(event, this.relays);
if (result.failed.length > 0 && result.success.length === 0) {
throw new Error('Failed to publish status update to all relays');
}
return event as StatusEvent;
}
}

95
src/lib/services/nostr/repo-verification.ts

@ -0,0 +1,95 @@ @@ -0,0 +1,95 @@
/**
* Service for verifying repository ownership
* Creates and verifies cryptographic proof linking repo announcements to git repos
*/
import { getEventHash, verifyEvent } from 'nostr-tools';
import type { NostrEvent } from '../../types/nostr.js';
import { nip19 } from 'nostr-tools';
export interface VerificationFile {
eventId: string;
pubkey: string;
npub: string;
signature: string;
timestamp: number;
}
/**
* Generate a verification file content for a repository
* This file should be committed to the repository to prove ownership
*/
export function generateVerificationFile(
announcementEvent: NostrEvent,
ownerPubkey: string
): string {
const npub = nip19.npubEncode(ownerPubkey);
const verification: VerificationFile = {
eventId: announcementEvent.id,
pubkey: ownerPubkey,
npub: npub,
signature: announcementEvent.sig,
timestamp: announcementEvent.created_at
};
// Create a JSON file with clear formatting
return JSON.stringify(verification, null, 2) + '\n';
}
/**
* Verify that a repository announcement matches the verification file in the repo
*/
export function verifyRepositoryOwnership(
announcementEvent: NostrEvent,
verificationFileContent: string
): { valid: boolean; error?: string } {
try {
// Parse verification file
const verification: VerificationFile = JSON.parse(verificationFileContent);
// Check that the event ID matches
if (verification.eventId !== announcementEvent.id) {
return {
valid: false,
error: 'Verification file event ID does not match announcement'
};
}
// Check that the pubkey matches
if (verification.pubkey !== announcementEvent.pubkey) {
return {
valid: false,
error: 'Verification file pubkey does not match announcement author'
};
}
// Verify the announcement event signature
if (!verifyEvent(announcementEvent)) {
return {
valid: false,
error: 'Announcement event signature is invalid'
};
}
// Verify the signature in the verification file matches the announcement
if (verification.signature !== announcementEvent.sig) {
return {
valid: false,
error: 'Verification file signature does not match announcement'
};
}
return { valid: true };
} catch (error) {
return {
valid: false,
error: `Failed to parse verification file: ${error instanceof Error ? error.message : String(error)}`
};
}
}
/**
* Get the path where the verification file should be stored
*/
export const VERIFICATION_FILE_PATH = '.nostr-verification';

21
src/lib/types/nostr.ts

@ -26,4 +26,25 @@ export interface NostrFilter { @@ -26,4 +26,25 @@ export interface NostrFilter {
export const KIND = {
REPO_ANNOUNCEMENT: 30617,
REPO_STATE: 30618,
PATCH: 1617,
PULL_REQUEST: 1618,
PULL_REQUEST_UPDATE: 1619,
ISSUE: 1621,
STATUS_OPEN: 1630,
STATUS_APPLIED: 1631,
STATUS_CLOSED: 1632,
STATUS_DRAFT: 1633,
} as const;
export interface Issue extends NostrEvent {
kind: typeof KIND.ISSUE;
}
export interface PullRequest extends NostrEvent {
kind: typeof KIND.PULL_REQUEST;
}
export interface StatusEvent extends NostrEvent {
kind: typeof KIND.STATUS_OPEN | typeof KIND.STATUS_APPLIED | typeof KIND.STATUS_CLOSED | typeof KIND.STATUS_DRAFT;
}

85
src/routes/+page.svelte

@ -5,18 +5,15 @@ @@ -5,18 +5,15 @@
import { NostrClient } from '../lib/services/nostr/nostr-client.js';
import { KIND } from '../lib/types/nostr.js';
import type { NostrEvent } from '../lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
let repos = $state<NostrEvent[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
const relays = [
'wss://theforest.nostr1.com',
'wss://nostr.land',
'wss://relay.damus.io'
];
import { DEFAULT_NOSTR_RELAYS } from '../lib/config.js';
const nostrClient = new NostrClient(relays);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
onMount(async () => {
await loadRepos();
@ -85,6 +82,51 @@ @@ -85,6 +82,51 @@
return urls;
}
function getNpubFromEvent(event: NostrEvent): string {
// Extract npub from clone URLs that match our domain
const gitDomain = $page.data.gitDomain || 'localhost:6543';
const cloneUrls = getCloneUrls(event);
for (const url of cloneUrls) {
if (url.includes(gitDomain)) {
// Extract npub from URL: https://domain/npub.../repo.git
const match = url.match(/\/(npub[a-z0-9]+)\//);
if (match) {
return match[1];
}
}
}
// Fallback: convert pubkey to npub if needed
try {
if (event.pubkey.startsWith('npub')) {
return event.pubkey;
}
return nip19.npubEncode(event.pubkey);
} catch {
// If conversion fails, return pubkey as-is
return event.pubkey;
}
}
function getRepoNameFromUrl(event: NostrEvent): string {
const gitDomain = $page.data.gitDomain || 'localhost:6543';
const cloneUrls = getCloneUrls(event);
for (const url of cloneUrls) {
if (url.includes(gitDomain)) {
// Extract repo name from URL: https://domain/npub.../repo-name.git
const match = url.match(/\/(npub[a-z0-9]+)\/([^\/]+)\.git/);
if (match) {
return match[2];
}
}
}
// Fallback to repo name from event
return getRepoName(event);
}
</script>
<div class="container">
@ -93,6 +135,7 @@ @@ -93,6 +135,7 @@
<nav>
<a href="/">Repositories</a>
<a href="/signup">Sign Up</a>
<a href="/docs/nip34">NIP-34 Docs</a>
</nav>
</header>
@ -116,7 +159,12 @@ @@ -116,7 +159,12 @@
<div class="repos-list">
{#each repos as repo}
<div class="repo-card">
<h3>{getRepoName(repo)}</h3>
<div class="repo-header">
<h3>{getRepoName(repo)}</h3>
<a href="/repos/{getNpubFromEvent(repo)}/{getRepoNameFromUrl(repo)}" class="view-button">
View & Edit →
</a>
</div>
{#if getRepoDescription(repo)}
<p class="description">{getRepoDescription(repo)}</p>
{/if}
@ -181,11 +229,32 @@ @@ -181,11 +229,32 @@
background: white;
}
.repo-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.repo-card h3 {
margin: 0 0 0.5rem 0;
margin: 0;
font-size: 1.25rem;
}
.view-button {
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
text-decoration: none;
border-radius: 0.25rem;
font-size: 0.875rem;
white-space: nowrap;
}
.view-button:hover {
background: #2563eb;
}
.description {
color: #6b7280;
margin: 0.5rem 0;

96
src/routes/api/repos/[npub]/[repo]/branches/+server.ts

@ -0,0 +1,96 @@ @@ -0,0 +1,96 @@
/**
* API endpoint for getting and creating repository branches
*/
import { json, error } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ params }: { params: { npub?: string; repo?: string } }) => {
const { npub, repo } = params;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found');
}
const branches = await fileManager.getBranches(npub, repo);
return json(branches);
} catch (err) {
console.error('Error getting branches:', err);
return error(500, err instanceof Error ? err.message : 'Failed to get branches');
}
};
export const POST: RequestHandler = async ({ params, request }: { params: { npub?: string; repo?: string }; request: Request }) => {
const { npub, repo } = params;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
const body = await request.json();
const { branchName, fromBranch, userPubkey } = body;
if (!branchName) {
return error(400, 'Missing branchName parameter');
}
if (!userPubkey) {
return error(401, 'Authentication required. Please provide userPubkey.');
}
if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found');
}
// Check if user is a maintainer
let repoOwnerPubkey: string;
try {
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
repoOwnerPubkey = decoded.data as string;
} else {
return error(400, 'Invalid npub format');
}
} catch {
return error(400, 'Invalid npub format');
}
// Convert userPubkey to hex if needed
let userPubkeyHex = userPubkey;
try {
const userDecoded = nip19.decode(userPubkey);
// @ts-ignore - nip19 types are incomplete, but we know npub returns string
if (userDecoded.type === 'npub') {
userPubkeyHex = userDecoded.data as unknown as string;
}
} catch {
// Assume it's already a hex pubkey
}
const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, repoOwnerPubkey, repo);
if (!isMaintainer) {
return error(403, 'Only repository maintainers can create branches. Please submit a pull request instead.');
}
await fileManager.createBranch(npub, repo, branchName, fromBranch || 'main');
return json({ success: true, message: 'Branch created successfully' });
} catch (err) {
console.error('Error creating branch:', err);
return error(500, err instanceof Error ? err.message : 'Failed to create branch');
}
};

33
src/routes/api/repos/[npub]/[repo]/commits/+server.ts

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
/**
* API endpoint for getting commit history
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
export const GET: RequestHandler = async ({ params, url }) => {
const { npub, repo } = params;
const branch = url.searchParams.get('branch') || 'main';
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
const path = url.searchParams.get('path') || undefined;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found');
}
const commits = await fileManager.getCommitHistory(npub, repo, branch, limit, path);
return json(commits);
} catch (err) {
console.error('Error getting commit history:', err);
return error(500, err instanceof Error ? err.message : 'Failed to get commit history');
}
};

33
src/routes/api/repos/[npub]/[repo]/diff/+server.ts

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
/**
* API endpoint for getting diffs
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
export const GET: RequestHandler = async ({ params, url }) => {
const { npub, repo } = params;
const fromRef = url.searchParams.get('from');
const toRef = url.searchParams.get('to') || 'HEAD';
const filePath = url.searchParams.get('path') || undefined;
if (!npub || !repo || !fromRef) {
return error(400, 'Missing npub, repo, or from parameter');
}
try {
if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found');
}
const diffs = await fileManager.getDiff(npub, repo, fromRef, toRef, filePath);
return json(diffs);
} catch (err) {
console.error('Error getting diff:', err);
return error(500, err instanceof Error ? err.message : 'Failed to get diff');
}
};

125
src/routes/api/repos/[npub]/[repo]/file/+server.ts

@ -0,0 +1,125 @@ @@ -0,0 +1,125 @@
/**
* API endpoint for reading and writing files in a repository
*/
import { json, error } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ params, url }: { params: { npub?: string; repo?: string }; url: URL }) => {
const { npub, repo } = params;
const filePath = url.searchParams.get('path');
const ref = url.searchParams.get('ref') || 'HEAD';
if (!npub || !repo || !filePath) {
return error(400, 'Missing npub, repo, or path parameter');
}
try {
if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found');
}
const fileContent = await fileManager.getFileContent(npub, repo, filePath, ref);
return json(fileContent);
} catch (err) {
console.error('Error reading file:', err);
return error(500, err instanceof Error ? err.message : 'Failed to read file');
}
};
export const POST: RequestHandler = async ({ params, request }: { params: { npub?: string; repo?: string }; request: Request }) => {
const { npub, repo } = params;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
const body = await request.json();
const { path, content, commitMessage, authorName, authorEmail, branch, action, userPubkey } = body;
if (!path || !commitMessage || !authorName || !authorEmail) {
return error(400, 'Missing required fields: path, commitMessage, authorName, authorEmail');
}
if (!userPubkey) {
return error(401, 'Authentication required. Please provide userPubkey.');
}
if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found');
}
// Check if user is a maintainer
let repoOwnerPubkey: string;
try {
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
repoOwnerPubkey = decoded.data as string;
} else {
return error(400, 'Invalid npub format');
}
} catch {
return error(400, 'Invalid npub format');
}
// Convert userPubkey to hex if needed
let userPubkeyHex = userPubkey;
try {
const userDecoded = nip19.decode(userPubkey);
// @ts-ignore - nip19 types are incomplete, but we know npub returns string
if (userDecoded.type === 'npub') {
userPubkeyHex = userDecoded.data as unknown as string;
}
} catch {
// Assume it's already a hex pubkey
}
const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, repoOwnerPubkey, repo);
if (!isMaintainer) {
return error(403, 'Only repository maintainers can edit files directly. Please submit a pull request instead.');
}
if (action === 'delete') {
await fileManager.deleteFile(
npub,
repo,
path,
commitMessage,
authorName,
authorEmail,
branch || 'main'
);
return json({ success: true, message: 'File deleted and committed' });
} else if (action === 'create' || content !== undefined) {
if (content === undefined) {
return error(400, 'Content is required for create/update operations');
}
await fileManager.writeFile(
npub,
repo,
path,
content,
commitMessage,
authorName,
authorEmail,
branch || 'main'
);
return json({ success: true, message: 'File saved and committed' });
} else {
return error(400, 'Invalid action or missing content');
}
} catch (err) {
console.error('Error writing file:', err);
return error(500, err instanceof Error ? err.message : 'Failed to write file');
}
};

71
src/routes/api/repos/[npub]/[repo]/issues/+server.ts

@ -0,0 +1,71 @@ @@ -0,0 +1,71 @@
/**
* API endpoint for Issues (NIP-34 kind 1621)
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { IssuesService } from '$lib/services/nostr/issues-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools';
export const GET: RequestHandler = async ({ params, url }) => {
const { npub, repo } = params;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
// Convert npub to pubkey
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
return error(400, 'Invalid npub format');
}
const repoOwnerPubkey = decoded.data as string;
const issuesService = new IssuesService(DEFAULT_NOSTR_RELAYS);
const issues = await issuesService.getIssues(repoOwnerPubkey, repo);
return json(issues);
} catch (err) {
console.error('Error fetching issues:', err);
return error(500, err instanceof Error ? err.message : 'Failed to fetch issues');
}
};
export const POST: RequestHandler = async ({ params, request }) => {
// For creating issues, we accept a pre-signed event from the client
// since NIP-07 signing must happen client-side
const { npub, repo } = params;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
const body = await request.json();
const { event } = body;
if (!event) {
return error(400, 'Missing event in request body');
}
// Verify the event is properly signed (basic check)
if (!event.sig || !event.id) {
return error(400, 'Invalid event: missing signature or ID');
}
// Publish the event to relays
const issuesService = new IssuesService(DEFAULT_NOSTR_RELAYS);
const result = await issuesService['nostrClient'].publishEvent(event, DEFAULT_NOSTR_RELAYS);
if (result.failed.length > 0 && result.success.length === 0) {
return error(500, 'Failed to publish issue to all relays');
}
return json({ success: true, event, published: result });
} catch (err) {
console.error('Error creating issue:', err);
return error(500, err instanceof Error ? err.message : 'Failed to create issue');
}
};

66
src/routes/api/repos/[npub]/[repo]/maintainers/+server.ts

@ -0,0 +1,66 @@ @@ -0,0 +1,66 @@
/**
* API endpoint for checking maintainer status
*/
import { json, error } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools';
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ params, url }: { params: { npub?: string; repo?: string }; url: URL }) => {
const { npub, repo } = params;
const userPubkey = url.searchParams.get('userPubkey');
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
// Convert npub to pubkey
let repoOwnerPubkey: string;
try {
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
repoOwnerPubkey = decoded.data as string;
} else {
return error(400, 'Invalid npub format');
}
} catch {
return error(400, 'Invalid npub format');
}
const { maintainers, owner } = await maintainerService.getMaintainers(repoOwnerPubkey, repo);
// If userPubkey provided, check if they're a maintainer
if (userPubkey) {
let userPubkeyHex = userPubkey;
try {
// Try to decode if it's an npub
const userDecoded = nip19.decode(userPubkey);
// @ts-ignore - nip19 types are incomplete, but we know npub returns string
if (userDecoded.type === 'npub') {
userPubkeyHex = userDecoded.data as unknown as string;
}
} catch {
// Assume it's already a hex pubkey
}
const isMaintainer = maintainers.includes(userPubkeyHex);
return json({
maintainers,
owner,
isMaintainer,
userPubkey: userPubkeyHex
});
}
return json({ maintainers, owner });
} catch (err) {
console.error('Error checking maintainers:', err);
return error(500, err instanceof Error ? err.message : 'Failed to check maintainers');
}
};

77
src/routes/api/repos/[npub]/[repo]/prs/+server.ts

@ -0,0 +1,77 @@ @@ -0,0 +1,77 @@
/**
* API endpoint for Pull Requests (NIP-34 kind 1618)
*/
import { json, error } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types';
import { PRsService } from '$lib/services/nostr/prs-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools';
export const GET: RequestHandler = async ({ params }: { params: { npub?: string; repo?: string } }) => {
const { npub, repo } = params;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
// Convert npub to pubkey
let repoOwnerPubkey: string;
try {
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
repoOwnerPubkey = decoded.data as string;
} else {
return error(400, 'Invalid npub format');
}
} catch {
return error(400, 'Invalid npub format');
}
const prsService = new PRsService(DEFAULT_NOSTR_RELAYS);
const prs = await prsService.getPullRequests(repoOwnerPubkey, repo);
return json(prs);
} catch (err) {
console.error('Error fetching pull requests:', err);
return error(500, err instanceof Error ? err.message : 'Failed to fetch pull requests');
}
};
export const POST: RequestHandler = async ({ params, request }: { params: { npub?: string; repo?: string }; request: Request }) => {
// For creating PRs, we accept a pre-signed event from the client
const { npub, repo } = params;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
const body = await request.json();
const { event } = body;
if (!event) {
return error(400, 'Missing event in request body');
}
// Verify the event is properly signed
if (!event.sig || !event.id) {
return error(400, 'Invalid event: missing signature or ID');
}
// Publish the event to relays
const prsService = new PRsService(DEFAULT_NOSTR_RELAYS);
const result = await prsService['nostrClient'].publishEvent(event, DEFAULT_NOSTR_RELAYS);
if (result.failed.length > 0 && result.success.length === 0) {
return error(500, 'Failed to publish pull request to all relays');
}
return json({ success: true, event, published: result });
} catch (err) {
console.error('Error creating pull request:', err);
return error(500, err instanceof Error ? err.message : 'Failed to create pull request');
}
};

96
src/routes/api/repos/[npub]/[repo]/tags/+server.ts

@ -0,0 +1,96 @@ @@ -0,0 +1,96 @@
/**
* API endpoint for getting and creating tags
*/
import { json, error } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ params }: { params: { npub?: string; repo?: string } }) => {
const { npub, repo } = params;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found');
}
const tags = await fileManager.getTags(npub, repo);
return json(tags);
} catch (err) {
console.error('Error getting tags:', err);
return error(500, err instanceof Error ? err.message : 'Failed to get tags');
}
};
export const POST: RequestHandler = async ({ params, request }: { params: { npub?: string; repo?: string }; request: Request }) => {
const { npub, repo } = params;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
const body = await request.json();
const { tagName, ref, message, userPubkey } = body;
if (!tagName) {
return error(400, 'Missing tagName parameter');
}
if (!userPubkey) {
return error(401, 'Authentication required. Please provide userPubkey.');
}
if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found');
}
// Check if user is a maintainer
let repoOwnerPubkey: string;
try {
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
repoOwnerPubkey = decoded.data as string;
} else {
return error(400, 'Invalid npub format');
}
} catch {
return error(400, 'Invalid npub format');
}
// Convert userPubkey to hex if needed
let userPubkeyHex = userPubkey;
try {
const userDecoded = nip19.decode(userPubkey);
// @ts-ignore - nip19 types are incomplete, but we know npub returns string
if (userDecoded.type === 'npub') {
userPubkeyHex = userDecoded.data as unknown as string;
}
} catch {
// Assume it's already a hex pubkey
}
const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, repoOwnerPubkey, repo);
if (!isMaintainer) {
return error(403, 'Only repository maintainers can create tags.');
}
await fileManager.createTag(npub, repo, tagName, ref || 'HEAD', message);
return json({ success: true, message: 'Tag created successfully' });
} catch (err) {
console.error('Error creating tag:', err);
return error(500, err instanceof Error ? err.message : 'Failed to create tag');
}
};

32
src/routes/api/repos/[npub]/[repo]/tree/+server.ts

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
/**
* API endpoint for listing files and directories in a repository
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
export const GET: RequestHandler = async ({ params, url }) => {
const { npub, repo } = params;
const ref = url.searchParams.get('ref') || 'HEAD';
const path = url.searchParams.get('path') || '';
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found');
}
const files = await fileManager.listFiles(npub, repo, ref, path);
return json(files);
} catch (err) {
console.error('Error listing files:', err);
return error(500, err instanceof Error ? err.message : 'Failed to list files');
}
};

103
src/routes/api/repos/[npub]/[repo]/verify/+server.ts

@ -0,0 +1,103 @@ @@ -0,0 +1,103 @@
/**
* API endpoint for verifying repository ownership
*/
import { json, error } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js';
import { verifyRepositoryOwnership, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { existsSync } from 'fs';
import { join } from 'path';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ params }: { params: { npub?: string; repo?: string } }) => {
const { npub, repo } = params;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
try {
// Decode npub to get pubkey
let ownerPubkey: string;
try {
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
ownerPubkey = decoded.data as string;
} else {
return error(400, 'Invalid npub format');
}
} catch {
return error(400, 'Invalid npub format');
}
// Check if repository exists (using FileManager's internal method)
const repoPath = join(repoRoot, npub, `${repo}.git`);
if (!existsSync(repoPath)) {
return error(404, 'Repository not found');
}
// Try to read verification file
let verificationContent: string;
try {
const verificationFile = await fileManager.getFileContent(npub, repo, VERIFICATION_FILE_PATH, 'HEAD');
verificationContent = verificationFile.content;
} catch (err) {
return json({
verified: false,
error: 'Verification file not found in repository',
message: 'This repository does not have a .nostr-verification file. It may have been created before verification was implemented.'
});
}
// Fetch the repository announcement
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [ownerPubkey],
'#d': [repo],
limit: 1
}
]);
if (events.length === 0) {
return json({
verified: false,
error: 'Repository announcement not found',
message: 'Could not find a NIP-34 repository announcement for this repository.'
});
}
const announcement = events[0];
// Verify ownership
const verification = verifyRepositoryOwnership(announcement, verificationContent);
if (verification.valid) {
return json({
verified: true,
announcementId: announcement.id,
ownerPubkey: ownerPubkey,
message: 'Repository ownership verified successfully'
});
} else {
return json({
verified: false,
error: verification.error,
announcementId: announcement.id,
message: 'Repository ownership verification failed'
});
}
} catch (err) {
console.error('Error verifying repository:', err);
return error(500, err instanceof Error ? err.message : 'Failed to verify repository');
}
};

19
src/routes/docs/nip34/+page.server.ts

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
/**
* Server-side loader for NIP-34 documentation
*/
import { readFile } from 'fs/promises';
import { join } from 'path';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
try {
// Read NIP-34.md from the project root
const filePath = join(process.cwd(), 'NIP-34.md');
const content = await readFile(filePath, 'utf-8');
return { content };
} catch (error) {
console.error('Error loading NIP-34.md:', error);
return { content: null, error: 'Failed to load documentation' };
}
};

185
src/routes/docs/nip34/+page.svelte

@ -0,0 +1,185 @@ @@ -0,0 +1,185 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
let content = $state('');
let loading = $state(true);
let error = $state<string | null>(null);
onMount(async () => {
try {
const docContent = $page.data.content;
if (docContent) {
const { marked } = await import('marked');
content = marked.parse(docContent) as string;
} else {
error = $page.data.error || 'Failed to load NIP-34 documentation';
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load documentation';
console.error('Error parsing NIP-34.md:', err);
} finally {
loading = false;
}
});
</script>
<div class="container">
<header>
<a href="/" class="back-link">← Back to Repositories</a>
<h1>NIP-34 Documentation</h1>
<p class="subtitle">Git collaboration using Nostr</p>
</header>
<main class="docs-content">
{#if loading}
<div class="loading">Loading documentation...</div>
{:else if error}
<div class="error">{error}</div>
{:else}
<div class="markdown-content">
{@html content}
</div>
{/if}
</main>
</div>
<style>
.container {
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
}
header {
margin-bottom: 2rem;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 1rem;
}
.back-link {
color: #3b82f6;
text-decoration: none;
font-size: 0.875rem;
display: inline-block;
margin-bottom: 0.5rem;
}
.back-link:hover {
text-decoration: underline;
}
header h1 {
margin: 0 0 0.5rem 0;
font-size: 2rem;
}
.subtitle {
color: #6b7280;
margin: 0;
}
.docs-content {
background: white;
padding: 2rem;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.loading, .error {
text-align: center;
padding: 2rem;
}
.error {
color: #dc2626;
background: #fee2e2;
border-radius: 0.5rem;
}
:global(.markdown-content) {
line-height: 1.6;
}
:global(.markdown-content h1) {
font-size: 2rem;
margin-top: 2rem;
margin-bottom: 1rem;
border-bottom: 2px solid #e5e7eb;
padding-bottom: 0.5rem;
}
:global(.markdown-content h2) {
font-size: 1.5rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
color: #1f2937;
}
:global(.markdown-content h3) {
font-size: 1.25rem;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
color: #374151;
}
:global(.markdown-content code) {
background: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-family: 'Courier New', monospace;
font-size: 0.875em;
}
:global(.markdown-content pre) {
background: #1f2937;
color: #f9fafb;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 1rem 0;
}
:global(.markdown-content pre code) {
background: transparent;
padding: 0;
color: inherit;
}
:global(.markdown-content p) {
margin: 1rem 0;
}
:global(.markdown-content ul, .markdown-content ol) {
margin: 1rem 0;
padding-left: 2rem;
}
:global(.markdown-content li) {
margin: 0.5rem 0;
}
:global(.markdown-content blockquote) {
border-left: 4px solid #3b82f6;
padding-left: 1rem;
margin: 1rem 0;
color: #6b7280;
}
:global(.markdown-content table) {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
:global(.markdown-content th, .markdown-content td) {
border: 1px solid #e5e7eb;
padding: 0.5rem;
text-align: left;
}
:global(.markdown-content th) {
background: #f9fafb;
font-weight: 600;
}
</style>

2017
src/routes/repos/[npub]/[repo]/+page.svelte

File diff suppressed because it is too large Load Diff

12
src/routes/signup/+page.svelte

@ -21,13 +21,9 @@ @@ -21,13 +21,9 @@
let existingRepoRef = $state(''); // hex, nevent, or naddr
let loadingExisting = $state(false);
const relays = [
'wss://theforest.nostr1.com',
'wss://nostr.land',
'wss://relay.damus.io'
];
import { DEFAULT_NOSTR_RELAYS, combineRelays } from '../../lib/config.js';
const nostrClient = new NostrClient(relays);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
onMount(() => {
nip07Available = isNIP07Available();
@ -169,7 +165,7 @@ @@ -169,7 +165,7 @@
['name', repoName],
...(description ? [['description', description]] : []),
...allCloneUrls.map(url => ['clone', url]),
['relays', ...relays]
['relays', ...DEFAULT_NOSTR_RELAYS]
];
// Build event
@ -189,7 +185,7 @@ @@ -189,7 +185,7 @@
const { inbox, outbox } = await getUserRelays(pubkey, nostrClient);
// Combine user's outbox with default relays
const userRelays = [...new Set([...outbox, ...relays])];
const userRelays = combineRelays(outbox);
// Publish to user's outboxes and standard relays
const result = await nostrClient.publishEvent(signedEvent, userRelays);

248
static/NIP-34.md

@ -0,0 +1,248 @@ @@ -0,0 +1,248 @@
NIP-34
======
`git` stuff
-----------
`draft` `optional`
This NIP defines all the ways code collaboration using and adjacent to [`git`](https://git-scm.com/) can be done using Nostr.
## Repository announcements
Git repositories are hosted in Git-enabled servers, but their existence can be announced using Nostr events. By doing so the author asserts themselves as a maintainer and expresses a willingness to receive patches, bug reports and comments in general, unless `t` tag `personal-fork` is included.
```jsonc
{
"kind": 30617,
"content": "",
"tags": [
["d", "<repo-id>"], // usually kebab-case short name
["name", "<human-readable project name>"],
["description", "brief human-readable project description>"],
["web", "<url for browsing>", ...], // a webpage url, if the git server being used provides such a thing
["clone", "<url for git-cloning>", ...], // a url to be given to `git clone` so anyone can clone it
["relays", "<relay-url>", ...], // relays that this repository will monitor for patches and issues
["r", "<earliest-unique-commit-id>", "euc"],
["maintainers", "<other-recognized-maintainer>", ...],
["t","personal-fork"], // optionally indicate author isn't a maintainer
["t", "<arbitrary string>"], // hashtags labelling the repository
]
}
```
The tags `web`, `clone`, `relays`, `maintainers` can have multiple values.
The `r` tag annotated with the `"euc"` marker should be the commit ID of the earliest unique commit of this repo, made to identify it among forks and group it with other repositories hosted elsewhere that may represent essentially the same project. In most cases it will be the root commit of a repository. In case of a permanent fork between two projects, then the first commit after the fork should be used.
Except `d`, all tags are optional.
## Repository state announcements
An optional source of truth for the state of branches and tags in a repository.
```jsonc
{
"kind": 30618,
"content": "",
"tags": [
["d", "<repo-id>"], // matches the identifier in the corresponding repository announcement
["refs/<heads|tags>/<branch-or-tag-name>","<commit-id>"]
["HEAD", "ref: refs/heads/<branch-name>"]
]
}
```
The `refs` tag may appear multiple times, or none.
If no `refs` tags are present, the author is no longer tracking repository state using this event. This approach enables the author to restart tracking state at a later time unlike [NIP-09](09.md) deletion requests.
The `refs` tag can be optionally extended to enable clients to identify how many commits ahead a ref is:
```jsonc
{
"tags": [
["refs/<heads|tags>/<branch-or-tag-name>", "<commit-id>", "<shorthand-parent-commit-id>", "<shorthand-grandparent>", ...],
]
}
```
## Patches and Pull Requests (PRs)
Patches and PRs can be sent by anyone to any repository. Patches and PRs to a specific repository SHOULD be sent to the relays specified in that repository's announcement event's `"relays"` tag. Patch and PR events SHOULD include an `a` tag pointing to that repository's announcement address.
Patches SHOULD be used if each event is under 60kb, otherwise PRs SHOULD be used.
### Patches
Patches in a patch set SHOULD include a [NIP-10](10.md) `e` `reply` tag pointing to the previous patch.
The first patch revision in a patch revision SHOULD include a [NIP-10](10.md) `e` `reply` to the original root patch.
```jsonc
{
"kind": 1617,
"content": "<patch>", // contents of <git format-patch>
"tags": [
["a", "30617:<base-repo-owner-pubkey>:<base-repo-id>"],
["r", "<earliest-unique-commit-id-of-repo>"] // so clients can subscribe to all patches sent to a local git repo
["p", "<repository-owner>"],
["p", "<other-user>"], // optionally send the patch to another user to bring it to their attention
["t", "root"], // omitted for additional patches in a series
// for the first patch in a revision
["t", "root-revision"],
// optional tags for when it is desirable that the merged patch has a stable commit id
// these fields are necessary for ensuring that the commit resulting from applying a patch
// has the same id as it had in the proposer's machine -- all these tags can be omitted
// if the maintainer doesn't care about these things
["commit", "<current-commit-id>"],
["r", "<current-commit-id>"] // so clients can find existing patches for a specific commit
["parent-commit", "<parent-commit-id>"],
["commit-pgp-sig", "-----BEGIN PGP SIGNATURE-----..."], // empty string for unsigned commit
["committer", "<name>", "<email>", "<timestamp>", "<timezone offset in minutes>"],
]
}
```
The first patch in a series MAY be a cover letter in the format produced by `git format-patch`.
### Pull Requests
The PR or PR update tip SHOULD be successfully pushed to `refs/nostr/<[PR|PR-Update]-event-id>` in all repositories listed in its `clone` tag before the event is signed.
An attempt SHOULD be made to push this ref to all repositories listed in the repository's announcement event's `"clone"` tag, for which their is reason to believe the user might have write access. This includes each [grasp server](https://njump.me/naddr1qvzqqqrhnypzpgqgmmc409hm4xsdd74sf68a2uyf9pwel4g9mfdg8l5244t6x4jdqy28wumn8ghj7un9d3shjtnwva5hgtnyv4mqqpt8wfshxuqlnvh8x) which can be identified using this method: `clone` tag includes `[http|https]://<grasp-path>/<valid-npub>/<string>.git` and `relays` tag includes `[ws/wss]://<grasp-path>`.
Clients MAY fallback to creating a 'personal-fork' `repository announcement` listing other grasp servers, e.g. from the `User grasp list`, for the purpose of serving the specified commit(s).
```jsonc
{
"kind": 1618,
"content": "<markdown text>",
"tags": [
["a", "30617:<base-repo-owner-pubkey>:<base-repo-id>"],
["r", "<earliest-unique-commit-id-of-repo>"] // so clients can subscribe to all PRs sent to a local git repo
["p", "<repository-owner>"],
["p", "<other-user>"], // optionally send the PR to another user to bring it to their attention
["subject", "<PR-subject>"],
["t", "<PR-label>"], // optional
["t", "<another-PR-label>"], // optional
["c", "<current-commit-id>"], // tip of the PR branch
["clone", "<clone-url>", ...], // at least one git clone url where commit can be downloaded
["branch-name", "<branch-name>"], // optional recommended branch name
["e", "<root-patch-event-id>"], // optionally indicate PR is a revision of an existing patch, which should be closed
["merge-base", "<commit-id>"], // optional: the most recent common ancestor with the target branch
]
}
```
### Pull Request Updates
A PR Update changes the tip of a referenced PR event.
```jsonc
{
"kind": 1619,
"content": "",
"tags": [
["a", "30617:<base-repo-owner-pubkey>:<base-repo-id>"],
["r", "<earliest-unique-commit-id-of-repo>"] // so clients can subscribe to all PRs sent to a local git repo
["p", "<repository-owner>"],
["p", "<other-user>"], // optionally send the PR to another user to bring it to their attention
// NIP-22 tags
["E", "<pull-request-event-id>"],
["P", "<pull-request-author>"],
["c", "<current-commit-id>"], // updated tip of PR
["clone", "<clone-url>", ...], // at least one git clone url where commit can be downloaded
["merge-base", "<commit-id>"], // optional: the most recent common ancestor with the target branch
]
}
```
## Issues
Issues are Markdown text that is just human-readable conversational threads related to the repository: bug reports, feature requests, questions or comments of any kind. Like patches, these SHOULD be sent to the relays specified in that repository's announcement event's `"relays"` tag.
Issues may have a `subject` tag, which clients can utilize to display a header. Additionally, one or more `t` tags may be included to provide labels for the issue.
```json
{
"kind": 1621,
"content": "<markdown text>",
"tags": [
["a", "30617:<base-repo-owner-pubkey>:<base-repo-id>"],
["p", "<repository-owner>"]
["subject", "<issue-subject>"]
["t", "<issue-label>"]
["t", "<another-issue-label>"]
]
}
```
## Replies
Replies to either a `kind:1621` (_issue_), `kind:1617` (_patch_) or `kind:1618` (_pull request_) event should follow [NIP-22 comment](22.md).
## Status
Root Patches, PRs and Issues have a Status that defaults to 'Open' and can be set by issuing Status events.
```jsonc
{
"kind": 1630, // Open
"kind": 1631, // Applied / Merged for Patches; Resolved for Issues
"kind": 1632, // Closed
"kind": 1633, // Draft
"content": "<markdown text>",
"tags": [
["e", "<issue-or-PR-or-original-root-patch-id-hex>", "", "root"],
["e", "<accepted-revision-root-id-hex>", "", "reply"], // for when revisions applied
["p", "<repository-owner>"],
["p", "<root-event-author>"],
["p", "<revision-author>"],
// optional for improved subscription filter efficiency
["a", "30617:<base-repo-owner-pubkey>:<base-repo-id>", "<relay-url>"],
["r", "<earliest-unique-commit-id-of-repo>"]
// optional for `1631` status
["q", "<applied-or-merged-patch-event-id>", "<relay-url>", "<pubkey>"], // for each
// when merged
["merge-commit", "<merge-commit-id>"]
["r", "<merge-commit-id>"]
// when applied
["applied-as-commits", "<commit-id-in-master-branch>", ...]
["r", "<applied-commit-id>"] // for each
]
}
```
The most recent Status event (by `created_at` date) from either the issue/patch author or a maintainer is considered valid.
The Status of a patch-revision is to either that of the root-patch, or `1632` (_Closed_) if the root-patch's Status is `1631` (_Applied/Merged_) and the patch-revision isn't tagged in the `1631` (_Applied/Merged_) event.
## User grasp list
List of [grasp servers](https://njump.me/naddr1qvzqqqrhnypzpgqgmmc409hm4xsdd74sf68a2uyf9pwel4g9mfdg8l5244t6x4jdqy28wumn8ghj7un9d3shjtnwva5hgtnyv4mqqpt8wfshxuqlnvh8x) the user generally wishes to use for NIP-34 related activity. It is similar in function to the NIP-65 relay list and NIP-B7 blossom list.
The event SHOULD include a list of `g` tags with grasp service websocket URLs in order of preference.
```jsonc
{
"kind": 10317,
"content": "",
"tags": [
["g", "<grasp-service-websocket-url>"], // zero or more grasp sever urls
]
}
```
## Possible things to be added later
- inline file comments kind (we probably need one for patches and a different one for merged files)
Loading…
Cancel
Save