nappup 1.0.11 → 1.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/package.json +1 -1
- package/src/helpers/app-metadata.js +54 -0
- package/src/helpers/stream.js +13 -0
- package/src/index.js +190 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Arthur França
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Napp Up!
|
|
2
|
+
|
|
3
|
+
```text
|
|
4
|
+
_ _ _ _ _
|
|
5
|
+
| \ | | __ _ _ __ _ __ | | | |_ __ | |
|
|
6
|
+
| \| |/ _` | '_ \| '_ \| | | | '_ \| |
|
|
7
|
+
| |\ | (_| | |_) | |_) | |_| | |_) |_|
|
|
8
|
+
|_| \_|\__,_| .__/| .__/ \___/| .__/(_)
|
|
9
|
+
|_| |_| |_|
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
**Napp Up!** is a powerful CLI tool for developers to effortlessly upload and manage Nostr applications. Ship your decentralized apps to the Nostr network with a single command.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
nappup [directory] [options]
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
### Arguments
|
|
21
|
+
|
|
22
|
+
- `[directory]`
|
|
23
|
+
The root directory of your application to upload. If omitted, defaults to the current working directory (`.`).
|
|
24
|
+
|
|
25
|
+
### Options
|
|
26
|
+
|
|
27
|
+
| Flag | Description |
|
|
28
|
+
|------|-------------|
|
|
29
|
+
| `-s <secret_key>` | Your Nostr secret key (hex format) used to sign the application event. See [Authentication](#authentication) for alternatives. |
|
|
30
|
+
| `-d <d_tag>` | The unique identifier (`d` tag) for your application. If omitted, defaults to the directory name. Avoid generic names like `dist` or `build` - use something unique among your other apps like `mycoolapp`. |
|
|
31
|
+
| `-r` | Force re-upload. By default, Napp Up! might skip files that haven't changed. Use this flag to ensure everything is pushed fresh. |
|
|
32
|
+
| `--main` | Publish to the **main** release channel. This is the default behavior. |
|
|
33
|
+
| `--next` | Publish to the **next** release channel. Ideal for beta testing or staging builds. |
|
|
34
|
+
| `--draft` | Publish to the **draft** release channel. Use this for internal testing or work-in-progress builds. |
|
|
35
|
+
|
|
36
|
+
## Authentication
|
|
37
|
+
|
|
38
|
+
Napp Up! supports multiple ways to provide your Nostr secret key:
|
|
39
|
+
|
|
40
|
+
1. **CLI flag**: Pass your hex-encoded secret key directly via `-s`:
|
|
41
|
+
```bash
|
|
42
|
+
nappup -s 0123456789abcdef...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
2. **Environment variable**: Set `NOSTR_SECRET_KEY` in your environment or a `.env` file:
|
|
46
|
+
```bash
|
|
47
|
+
export NOSTR_SECRET_KEY=0123456789abcdef...
|
|
48
|
+
nappup ./dist
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
3. **Auto-generated key**: If no key is provided, Napp Up! will generate a new keypair automatically and store it in your project's `.env` file for future use.
|
|
52
|
+
|
|
53
|
+
> **Note**: The secret key must be in **hex format**. If you have an `nsec`, convert it to hex first.
|
|
54
|
+
|
|
55
|
+
### Examples
|
|
56
|
+
|
|
57
|
+
Upload the current directory to the main channel:
|
|
58
|
+
```bash
|
|
59
|
+
nappup -s 0123456789abcdef...
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Or using an environment variable:
|
|
63
|
+
```bash
|
|
64
|
+
NOSTR_SECRET_KEY=0123456789abcdef... nappup
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Upload a specific `dist` folder with a custom identifier to the `next` channel:
|
|
68
|
+
```bash
|
|
69
|
+
nappup ./dist -s 0123456789abcdef... -d myapp --next
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Force re-upload a draft:
|
|
73
|
+
```bash
|
|
74
|
+
nappup ~/my-repos/projectx/build/projectx --draft -r
|
|
75
|
+
```
|
package/package.json
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function extractHtmlMetadata (htmlContent) {
|
|
2
|
+
let name
|
|
3
|
+
let description
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
const titleRegex = /<title[^>]*>([\s\S]*?)<\/title>/i
|
|
7
|
+
const titleMatch = htmlContent.match(titleRegex)
|
|
8
|
+
if (titleMatch && titleMatch[1]) {
|
|
9
|
+
name = titleMatch[1].trim()
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const metaDescRegex = /<meta\s+[^>]*name\s*=\s*["']description["'][^>]*content\s*=\s*["']([^"']+)["'][^>]*>/i
|
|
13
|
+
const metaDescMatch = htmlContent.match(metaDescRegex)
|
|
14
|
+
if (metaDescMatch && metaDescMatch[1]) {
|
|
15
|
+
description = metaDescMatch[1].trim()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!description) {
|
|
19
|
+
const altMetaDescRegex = /<meta\s+[^>]*content\s*=\s*["']([^"']+)["'][^>]*name\s*=\s*["']description["'][^>]*>/i
|
|
20
|
+
const altMetaDescMatch = htmlContent.match(altMetaDescRegex)
|
|
21
|
+
if (altMetaDescMatch && altMetaDescMatch[1]) {
|
|
22
|
+
description = altMetaDescMatch[1].trim()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
} catch (_) {
|
|
26
|
+
// ignore
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { name, description }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function findFavicon (fileList) {
|
|
33
|
+
const faviconExtensions = ['ico', 'svg', 'webp', 'png', 'jpg', 'jpeg', 'gif']
|
|
34
|
+
for (const file of fileList) {
|
|
35
|
+
const filename = (file.webkitRelativePath || file.name || '').split('/').pop().toLowerCase()
|
|
36
|
+
if (filename.startsWith('favicon.')) {
|
|
37
|
+
const ext = filename.split('.').pop()
|
|
38
|
+
if (faviconExtensions.includes(ext)) {
|
|
39
|
+
return file
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function findIndexFile (fileList) {
|
|
47
|
+
for (const file of fileList) {
|
|
48
|
+
const filename = (file.webkitRelativePath || file.name || '').split('/').pop().toLowerCase()
|
|
49
|
+
if (filename === 'index.html' || filename === 'index.htm') {
|
|
50
|
+
return file
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null
|
|
54
|
+
}
|
package/src/helpers/stream.js
CHANGED
|
@@ -18,3 +18,16 @@ export async function * streamToChunks (stream, chunkSize) {
|
|
|
18
18
|
|
|
19
19
|
if (buffer.length > 0) yield buffer
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
export async function streamToText (stream) {
|
|
23
|
+
const reader = stream.getReader()
|
|
24
|
+
let result = ''
|
|
25
|
+
const decoder = new TextDecoder()
|
|
26
|
+
while (true) {
|
|
27
|
+
const { done, value } = await reader.read()
|
|
28
|
+
if (done) break
|
|
29
|
+
result += decoder.decode(value, { stream: true })
|
|
30
|
+
}
|
|
31
|
+
result += decoder.decode()
|
|
32
|
+
return result
|
|
33
|
+
}
|
package/src/index.js
CHANGED
|
@@ -3,8 +3,9 @@ import { appEncode } from '#helpers/nip19.js'
|
|
|
3
3
|
import Base93Encoder from '#services/base93-encoder.js'
|
|
4
4
|
import nostrRelays from '#services/nostr-relays.js'
|
|
5
5
|
import NostrSigner from '#services/nostr-signer.js'
|
|
6
|
-
import { streamToChunks } from '#helpers/stream.js'
|
|
6
|
+
import { streamToChunks, streamToText } from '#helpers/stream.js'
|
|
7
7
|
import { isNostrAppDTagSafe, deriveNostrAppDTag } from '#helpers/app.js'
|
|
8
|
+
import { extractHtmlMetadata, findFavicon, findIndexFile } from '#helpers/app-metadata.js'
|
|
8
9
|
|
|
9
10
|
export default async function (...args) {
|
|
10
11
|
try {
|
|
@@ -33,6 +34,21 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, chan
|
|
|
33
34
|
let nmmr
|
|
34
35
|
const fileMetadata = []
|
|
35
36
|
|
|
37
|
+
const indexFile = findIndexFile(fileList)
|
|
38
|
+
let stallName, stallSummary
|
|
39
|
+
if (indexFile) {
|
|
40
|
+
try {
|
|
41
|
+
const htmlContent = await streamToText(indexFile.stream())
|
|
42
|
+
const { name, description } = extractHtmlMetadata(htmlContent)
|
|
43
|
+
stallName = name
|
|
44
|
+
stallSummary = description
|
|
45
|
+
} catch (err) {
|
|
46
|
+
log('Error extracting HTML metadata:', err)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const faviconFile = findFavicon(fileList)
|
|
50
|
+
let iconMetadata
|
|
51
|
+
|
|
36
52
|
log(`Processing ${fileList.length} files`)
|
|
37
53
|
let pause = 1000
|
|
38
54
|
for (const file of fileList) {
|
|
@@ -54,9 +70,29 @@ export async function toApp (fileList, nostrSigner, { log = () => {}, dTag, chan
|
|
|
54
70
|
filename,
|
|
55
71
|
mimeType: file.type || 'application/octet-stream'
|
|
56
72
|
})
|
|
73
|
+
|
|
74
|
+
if (faviconFile && file === faviconFile) {
|
|
75
|
+
iconMetadata = {
|
|
76
|
+
rootHash: nmmr.getRoot(),
|
|
77
|
+
mimeType: file.type || 'application/octet-stream'
|
|
78
|
+
}
|
|
79
|
+
}
|
|
57
80
|
}
|
|
58
81
|
}
|
|
59
82
|
|
|
83
|
+
log(`Uploading stall event for #${dTag}`)
|
|
84
|
+
;({ pause } = (await maybeUploadStall({
|
|
85
|
+
dTag,
|
|
86
|
+
channel,
|
|
87
|
+
name: stallName,
|
|
88
|
+
summary: stallSummary,
|
|
89
|
+
icon: iconMetadata,
|
|
90
|
+
signer: nostrSigner,
|
|
91
|
+
writeRelays,
|
|
92
|
+
log,
|
|
93
|
+
pause
|
|
94
|
+
})))
|
|
95
|
+
|
|
60
96
|
log(`Uploading bundle #${dTag}`)
|
|
61
97
|
const bundle = await uploadBundle({ dTag, channel, fileMetadata, signer: nostrSigner, pause })
|
|
62
98
|
|
|
@@ -203,3 +239,156 @@ async function uploadBundle ({ dTag, channel, fileMetadata, signer, pause = 0 })
|
|
|
203
239
|
await throttledSendEvent(event, (await signer.getRelays()).write, { pause, trailingPause: true })
|
|
204
240
|
return event
|
|
205
241
|
}
|
|
242
|
+
|
|
243
|
+
async function maybeUploadStall ({
|
|
244
|
+
dTag,
|
|
245
|
+
channel,
|
|
246
|
+
name,
|
|
247
|
+
summary,
|
|
248
|
+
icon,
|
|
249
|
+
signer,
|
|
250
|
+
writeRelays,
|
|
251
|
+
log,
|
|
252
|
+
pause
|
|
253
|
+
}) {
|
|
254
|
+
const trimmedName = typeof name === 'string' ? name.trim() : ''
|
|
255
|
+
const trimmedSummary = typeof summary === 'string' ? summary.trim() : ''
|
|
256
|
+
const iconRootHash = icon?.rootHash
|
|
257
|
+
const iconMimeType = icon?.mimeType
|
|
258
|
+
const hasMetadata = Boolean(trimmedName) || Boolean(trimmedSummary) || Boolean(iconRootHash)
|
|
259
|
+
|
|
260
|
+
const previous = await getPreviousStall(dTag, writeRelays, signer, channel)
|
|
261
|
+
if (!previous && !hasMetadata) return { pause }
|
|
262
|
+
|
|
263
|
+
const publishStall = async (event) => {
|
|
264
|
+
const signedEvent = await signer.signEvent(event)
|
|
265
|
+
return await throttledSendEvent(signedEvent, writeRelays, { pause, log, trailingPause: true })
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const createdAt = Math.floor(Date.now() / 1000)
|
|
269
|
+
const kind = {
|
|
270
|
+
main: 37348,
|
|
271
|
+
next: 37349,
|
|
272
|
+
draft: 37350
|
|
273
|
+
}[channel] ?? 37348
|
|
274
|
+
|
|
275
|
+
if (!previous) {
|
|
276
|
+
const tags = [
|
|
277
|
+
['d', dTag],
|
|
278
|
+
['c', '*']
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
let hasIcon = false
|
|
282
|
+
let hasName = false
|
|
283
|
+
if (iconRootHash && iconMimeType) {
|
|
284
|
+
hasIcon = true
|
|
285
|
+
tags.push(['icon', iconRootHash, iconMimeType])
|
|
286
|
+
tags.push(['auto', 'icon'])
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (trimmedName) {
|
|
290
|
+
hasName = true
|
|
291
|
+
tags.push(['name', trimmedName])
|
|
292
|
+
tags.push(['auto', 'name'])
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (trimmedSummary) {
|
|
296
|
+
tags.push(['summary', trimmedSummary])
|
|
297
|
+
tags.push(['auto', 'summary'])
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!hasIcon || !hasName) return { pause }
|
|
301
|
+
|
|
302
|
+
return await publishStall({
|
|
303
|
+
kind,
|
|
304
|
+
tags,
|
|
305
|
+
content: '',
|
|
306
|
+
created_at: createdAt
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const tags = Array.isArray(previous.tags)
|
|
311
|
+
? previous.tags.map(tag => (Array.isArray(tag) ? [...tag] : tag))
|
|
312
|
+
: []
|
|
313
|
+
let changed = false
|
|
314
|
+
|
|
315
|
+
const ensureTagValue = (key, updater) => {
|
|
316
|
+
const index = tags.findIndex(tag => Array.isArray(tag) && tag[0] === key)
|
|
317
|
+
if (index === -1) {
|
|
318
|
+
const next = updater(null)
|
|
319
|
+
if (!next) return
|
|
320
|
+
tags.push(next)
|
|
321
|
+
changed = true
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const next = updater(tags[index])
|
|
326
|
+
if (!next) return
|
|
327
|
+
if (!tags[index] || tags[index].some((value, idx) => value !== next[idx])) {
|
|
328
|
+
tags[index] = next
|
|
329
|
+
changed = true
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
ensureTagValue('d', (existing) => {
|
|
334
|
+
if (existing && existing[1] === dTag) return existing
|
|
335
|
+
return ['d', dTag]
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
ensureTagValue('c', (existing) => {
|
|
339
|
+
if (!existing) return ['c', '*']
|
|
340
|
+
const currentValue = typeof existing[1] === 'string' ? existing[1].trim() : ''
|
|
341
|
+
if (currentValue === '') return ['c', '*']
|
|
342
|
+
return existing
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
const hasAuto = (field) => tags.some(tag => Array.isArray(tag) && tag[0] === 'auto' && tag[1] === field)
|
|
346
|
+
|
|
347
|
+
if (trimmedName && hasAuto('name')) {
|
|
348
|
+
ensureTagValue('name', (existing) => {
|
|
349
|
+
if (existing && existing[1] === trimmedName) return existing
|
|
350
|
+
return ['name', trimmedName]
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (trimmedSummary && hasAuto('summary')) {
|
|
355
|
+
ensureTagValue('summary', (existing) => {
|
|
356
|
+
if (existing && existing[1] === trimmedSummary) return existing
|
|
357
|
+
return ['summary', trimmedSummary]
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (iconRootHash && iconMimeType && hasAuto('icon')) {
|
|
362
|
+
ensureTagValue('icon', (existing) => {
|
|
363
|
+
if (existing && existing[1] === iconRootHash && existing[2] === iconMimeType) return existing
|
|
364
|
+
return ['icon', iconRootHash, iconMimeType]
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!changed) return { pause }
|
|
369
|
+
|
|
370
|
+
return await publishStall({
|
|
371
|
+
kind,
|
|
372
|
+
tags,
|
|
373
|
+
content: typeof previous.content === 'string' ? previous.content : '',
|
|
374
|
+
created_at: createdAt
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function getPreviousStall (dTagValue, writeRelays, signer, channel) {
|
|
379
|
+
const kind = {
|
|
380
|
+
main: 37348,
|
|
381
|
+
next: 37349,
|
|
382
|
+
draft: 37350
|
|
383
|
+
}[channel] ?? 37348
|
|
384
|
+
|
|
385
|
+
const storedEvents = (await nostrRelays.getEvents({
|
|
386
|
+
kinds: [kind],
|
|
387
|
+
authors: [await signer.getPublicKey()],
|
|
388
|
+
'#d': [dTagValue],
|
|
389
|
+
limit: 1
|
|
390
|
+
}, writeRelays)).result
|
|
391
|
+
|
|
392
|
+
if (storedEvents.length === 0) return null
|
|
393
|
+
return storedEvents.sort((a, b) => b.created_at - a.created_at)[0]
|
|
394
|
+
}
|