nappup 1.0.11 → 1.0.13

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 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
@@ -6,7 +6,7 @@
6
6
  "url": "git+https://github.com/44billion/nappup.git"
7
7
  },
8
8
  "license": "GPL-3.0-or-later",
9
- "version": "1.0.11",
9
+ "version": "1.0.13",
10
10
  "description": "Nostr App Uploader",
11
11
  "type": "module",
12
12
  "scripts": {
@@ -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
+ }
@@ -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,158 @@ 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
+ // App stores are fetching stall events just from primal relay for now
266
+ const relays = [...new Set([...writeRelays, 'wss://relay.primal.net'])]
267
+ return await throttledSendEvent(signedEvent, relays, { pause, log, trailingPause: true })
268
+ }
269
+
270
+ const createdAt = Math.floor(Date.now() / 1000)
271
+ const kind = {
272
+ main: 37348,
273
+ next: 37349,
274
+ draft: 37350
275
+ }[channel] ?? 37348
276
+
277
+ if (!previous) {
278
+ const tags = [
279
+ ['d', dTag],
280
+ ['c', '*']
281
+ ]
282
+
283
+ let hasIcon = false
284
+ let hasName = false
285
+ if (iconRootHash && iconMimeType) {
286
+ hasIcon = true
287
+ tags.push(['icon', iconRootHash, iconMimeType])
288
+ tags.push(['auto', 'icon'])
289
+ }
290
+
291
+ if (trimmedName) {
292
+ hasName = true
293
+ tags.push(['name', trimmedName])
294
+ tags.push(['auto', 'name'])
295
+ }
296
+
297
+ if (trimmedSummary) {
298
+ tags.push(['summary', trimmedSummary])
299
+ tags.push(['auto', 'summary'])
300
+ }
301
+
302
+ if (!hasIcon || !hasName) return { pause }
303
+
304
+ return await publishStall({
305
+ kind,
306
+ tags,
307
+ content: '',
308
+ created_at: createdAt
309
+ })
310
+ }
311
+
312
+ const tags = Array.isArray(previous.tags)
313
+ ? previous.tags.map(tag => (Array.isArray(tag) ? [...tag] : tag))
314
+ : []
315
+ let changed = false
316
+
317
+ const ensureTagValue = (key, updater) => {
318
+ const index = tags.findIndex(tag => Array.isArray(tag) && tag[0] === key)
319
+ if (index === -1) {
320
+ const next = updater(null)
321
+ if (!next) return
322
+ tags.push(next)
323
+ changed = true
324
+ return
325
+ }
326
+
327
+ const next = updater(tags[index])
328
+ if (!next) return
329
+ if (!tags[index] || tags[index].some((value, idx) => value !== next[idx])) {
330
+ tags[index] = next
331
+ changed = true
332
+ }
333
+ }
334
+
335
+ ensureTagValue('d', (existing) => {
336
+ if (existing && existing[1] === dTag) return existing
337
+ return ['d', dTag]
338
+ })
339
+
340
+ ensureTagValue('c', (existing) => {
341
+ if (!existing) return ['c', '*']
342
+ const currentValue = typeof existing[1] === 'string' ? existing[1].trim() : ''
343
+ if (currentValue === '') return ['c', '*']
344
+ return existing
345
+ })
346
+
347
+ const hasAuto = (field) => tags.some(tag => Array.isArray(tag) && tag[0] === 'auto' && tag[1] === field)
348
+
349
+ if (trimmedName && hasAuto('name')) {
350
+ ensureTagValue('name', (existing) => {
351
+ if (existing && existing[1] === trimmedName) return existing
352
+ return ['name', trimmedName]
353
+ })
354
+ }
355
+
356
+ if (trimmedSummary && hasAuto('summary')) {
357
+ ensureTagValue('summary', (existing) => {
358
+ if (existing && existing[1] === trimmedSummary) return existing
359
+ return ['summary', trimmedSummary]
360
+ })
361
+ }
362
+
363
+ if (iconRootHash && iconMimeType && hasAuto('icon')) {
364
+ ensureTagValue('icon', (existing) => {
365
+ if (existing && existing[1] === iconRootHash && existing[2] === iconMimeType) return existing
366
+ return ['icon', iconRootHash, iconMimeType]
367
+ })
368
+ }
369
+
370
+ if (!changed) return { pause }
371
+
372
+ return await publishStall({
373
+ kind,
374
+ tags,
375
+ content: typeof previous.content === 'string' ? previous.content : '',
376
+ created_at: createdAt
377
+ })
378
+ }
379
+
380
+ async function getPreviousStall (dTagValue, writeRelays, signer, channel) {
381
+ const kind = {
382
+ main: 37348,
383
+ next: 37349,
384
+ draft: 37350
385
+ }[channel] ?? 37348
386
+
387
+ const storedEvents = (await nostrRelays.getEvents({
388
+ kinds: [kind],
389
+ authors: [await signer.getPublicKey()],
390
+ '#d': [dTagValue],
391
+ limit: 1
392
+ }, writeRelays)).result
393
+
394
+ if (storedEvents.length === 0) return null
395
+ return storedEvents.sort((a, b) => b.created_at - a.created_at)[0]
396
+ }