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 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.12",
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,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
+ }