shelfware 0.1.0 → 0.1.2

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.
Files changed (32) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/_nuxt/{BZuUK3cr.js → Bzmuhhqm.js} +1 -1
  3. package/.output/public/_nuxt/{6IstVYrV.js → CJmYvhe6.js} +1 -1
  4. package/.output/public/_nuxt/CMk5jOCS.js +1 -0
  5. package/.output/public/_nuxt/DJQqUjJu.js +2 -0
  6. package/.output/public/_nuxt/DfJzPSBi.js +1 -0
  7. package/.output/public/_nuxt/EBA28doP.js +14 -0
  8. package/.output/public/_nuxt/builds/latest.json +1 -1
  9. package/.output/public/_nuxt/builds/meta/bc384f67-a2f8-4b6b-9aa1-cb68fb93d366.json +1 -0
  10. package/.output/public/_nuxt/entry.CKteCcdN.css +2 -0
  11. package/.output/public/_nuxt/error-404.1qC5mjOI.css +1 -0
  12. package/.output/public/_nuxt/error-500.BcOmuN7K.css +1 -0
  13. package/.output/public/_nuxt/rWsrwf2L.js +36 -0
  14. package/.output/public/_nuxt/{C_VgD9ZI.js → sK9Xse3o.js} +1 -1
  15. package/.output/server/chunks/nitro/nitro.mjs +102 -102
  16. package/.output/server/chunks/routes/renderer.mjs +1 -1
  17. package/.output/server/chunks/virtual/precomputed.mjs +1 -1
  18. package/.output/server/package.json +1 -1
  19. package/README.md +11 -1
  20. package/bin/launch.d.mts +54 -0
  21. package/bin/launch.mjs +111 -24
  22. package/bin/shelfware.mjs +8 -3
  23. package/package.json +7 -3
  24. package/.output/public/_nuxt/9C5WUlLE.js +0 -36
  25. package/.output/public/_nuxt/BXXtFil5.js +0 -1
  26. package/.output/public/_nuxt/BeLyMmd4.js +0 -1
  27. package/.output/public/_nuxt/DB5Vxbte.js +0 -2
  28. package/.output/public/_nuxt/DMg0j7gv.js +0 -14
  29. package/.output/public/_nuxt/builds/meta/4c307968-4837-497b-a570-d6ee336accc4.json +0 -1
  30. package/.output/public/_nuxt/entry.b3_DJTEt.css +0 -2
  31. package/.output/public/_nuxt/error-404.CHsn7XF-.css +0 -1
  32. package/.output/public/_nuxt/error-500.Bawx2bLr.css +0 -1
package/bin/launch.mjs CHANGED
@@ -11,9 +11,12 @@ export const DEFAULT_PORT = 3781
11
11
  export const PORT_ATTEMPTS = 20
12
12
  export const HOST = '127.0.0.1'
13
13
 
14
+ /** Spec §10.3: the env var the server reads the session token from. */
15
+ export const TOKEN_ENV = 'NUXT_PUBLIC_SHELFWARE_TOKEN'
16
+
14
17
  export const HELP = `shelfware — a local catalog of the AI-agent skills on this machine
15
18
 
16
- Usage: shelfware [--port <n>] [--no-open]
19
+ Usage: shelfware [--port <n>] [--no-open] [--help]
17
20
 
18
21
  --port <n> bind 127.0.0.1:<n> instead of the first free port from ${DEFAULT_PORT}
19
22
  --no-open do not open the browser (also SHELFWARE_NO_OPEN=1)
@@ -26,38 +29,68 @@ export function makeToken() {
26
29
  return `sw_${crypto.randomBytes(32).toString('hex')}`
27
30
  }
28
31
 
32
+ /**
33
+ * The raw text stays in the message so `--port abc` reads back as "abc", not NaN.
34
+ * Decimal digits without a leading zero: `Number()` would otherwise accept `0x10`,
35
+ * `1e3`, `' 4000 '` and `065535`, none of which the help text (`--port <n>`) promises.
36
+ */
37
+ function parsePort(raw) {
38
+ if (!/^[1-9]\d*$/.test(raw)) throw new Error(`Invalid port: ${JSON.stringify(raw)}`)
39
+ const port = Number(raw)
40
+ if (port > 65535) throw new Error(`Invalid port: ${JSON.stringify(raw)}`)
41
+ return port
42
+ }
43
+
29
44
  export function parseArgs(argv = [], env = {}) {
30
45
  let port = null
31
46
  let open = env.SHELFWARE_NO_OPEN !== '1'
47
+ let optionsEnded = false
32
48
  for (let i = 0; i < argv.length; i += 1) {
33
49
  const arg = argv[i]
34
- if (arg === '--no-open') {
50
+ if (optionsEnded || !arg.startsWith('-')) {
51
+ throw new Error(`Unexpected argument: ${arg}`)
52
+ } else if (arg === '--') {
53
+ optionsEnded = true
54
+ } else if (arg === '--no-open') {
35
55
  open = false
36
56
  } else if (arg === '--port') {
37
- port = Number(argv[i + 1])
57
+ if (i + 1 >= argv.length) throw new Error('--port needs a value')
58
+ port = parsePort(argv[i + 1])
38
59
  i += 1
39
60
  } else if (arg.startsWith('--port=')) {
40
- port = Number(arg.slice('--port='.length))
61
+ port = parsePort(arg.slice('--port='.length))
41
62
  } else if (arg === '--help' || arg === '-h') {
42
63
  return { help: true, port: null, open }
64
+ } else {
65
+ throw new Error(`Unknown option: ${arg}`)
43
66
  }
44
67
  }
45
- if (port === null && env.PORT) port = Number(env.PORT)
46
- if (port !== null && (!Number.isInteger(port) || port < 1 || port > 65535)) {
47
- throw new Error(`Invalid port: ${port}`)
48
- }
68
+ // Unset or empty means unset; anything else must be a real port.
69
+ if (port === null && env.PORT !== undefined && env.PORT !== '') port = parsePort(env.PORT)
49
70
  return { help: false, port, open }
50
71
  }
51
72
 
52
- export function isPortFree(port, host = HOST) {
73
+ /** Why `port` cannot be bound, in words a user can act on. */
74
+ export function portReason(code) {
75
+ if (code === 'EADDRINUSE') return 'is already in use'
76
+ if (code === 'EACCES') return 'needs elevated privileges (EACCES)'
77
+ return `cannot be bound (${code})`
78
+ }
79
+
80
+ /** Resolves null when `port` can be bound on `host`, otherwise the reason (see `portReason`). */
81
+ export function checkPort(port, host = HOST) {
53
82
  return new Promise((resolve) => {
54
83
  const server = net.createServer()
55
84
  server.unref()
56
- server.once('error', () => resolve(false))
57
- server.listen({ port, host }, () => server.close(() => resolve(true)))
85
+ server.once('error', err => resolve(portReason(err.code ?? 'UNKNOWN')))
86
+ server.listen({ port, host }, () => server.close(() => resolve(null)))
58
87
  })
59
88
  }
60
89
 
90
+ export async function isPortFree(port, host = HOST) {
91
+ return (await checkPort(port, host)) === null
92
+ }
93
+
61
94
  export async function pickPort(start = DEFAULT_PORT, { attempts = PORT_ATTEMPTS, host = HOST } = {}) {
62
95
  for (let port = start; port < start + attempts; port += 1) {
63
96
  if (await isPortFree(port, host)) return port
@@ -87,24 +120,46 @@ export function probeHealth(port, host = HOST) {
87
120
  })
88
121
  }
89
122
 
90
- export async function waitForHealth(port, { timeoutMs = 10_000, intervalMs = 100, host = HOST, probe = probeHealth } = {}) {
123
+ export async function waitForHealth(port, { timeoutMs = 10_000, intervalMs = 100, host = HOST, probe = probeHealth, signal } = {}) {
91
124
  const deadline = Date.now() + timeoutMs
92
- while (Date.now() < deadline) {
125
+ while (Date.now() < deadline && !signal?.aborted) {
93
126
  if (await probe(port, host)) return true
94
- await new Promise(resolve => setTimeout(resolve, intervalMs))
127
+ await sleep(intervalMs, signal)
95
128
  }
96
129
  return false
97
130
  }
98
131
 
99
- export function openBrowser(url, { platform = process.platform, spawn = spawnProcess } = {}) {
132
+ /** Resolves after `ms`, or as soon as `signal` aborts and then leaves no timer behind. */
133
+ function sleep(ms, signal) {
134
+ return new Promise((resolve) => {
135
+ if (signal?.aborted) {
136
+ resolve()
137
+ return
138
+ }
139
+ const onAbort = () => {
140
+ clearTimeout(timer)
141
+ resolve()
142
+ }
143
+ const timer = setTimeout(() => {
144
+ signal?.removeEventListener('abort', onAbort)
145
+ resolve()
146
+ }, ms)
147
+ signal?.addEventListener('abort', onAbort, { once: true })
148
+ })
149
+ }
150
+
151
+ export function openBrowser(url, { platform = process.platform, spawn = spawnProcess, env = process.env } = {}) {
100
152
  const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open'
101
153
  const args = platform === 'win32' ? ['/c', 'start', '', url] : [url]
154
+ // The opener execs the browser; strip the session token so it never sits in the
155
+ // browser's environment (the served page is the only place it belongs).
156
+ const { [TOKEN_ENV]: _token, ...childEnv } = env
102
157
  // A missing browser opener (e.g. no xdg-open on a headless box) must never take
103
158
  // the running server down with it: spawn can throw synchronously (rare) or emit
104
159
  // an async 'error' (the common ENOENT case), and an unhandled 'error' on an
105
160
  // EventEmitter is fatal. Swallow both.
106
161
  try {
107
- const child = spawn(cmd, args, { stdio: 'ignore', detached: true })
162
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true, env: childEnv })
108
163
  child.on?.('error', () => {})
109
164
  child.unref?.()
110
165
  } catch {
@@ -117,6 +172,11 @@ export function serverEntry(binDir = path.dirname(fileURLToPath(import.meta.url)
117
172
  return path.resolve(binDir, '..', '.output', 'server', 'index.mjs')
118
173
  }
119
174
 
175
+ /** One line for the user whatever was thrown: Error, string, or nothing at all. */
176
+ export function describeError(err) {
177
+ return err instanceof Error ? err.message : String(err)
178
+ }
179
+
120
180
  /**
121
181
  * Orchestration with injectable I/O so it can be unit-tested end to end:
122
182
  * flags → port → env → import the prebuilt server → wait for health → open.
@@ -130,7 +190,15 @@ export async function launch({
130
190
  entry = serverEntry(),
131
191
  spawn = spawnProcess,
132
192
  } = {}) {
133
- const args = parseArgs(argv, env)
193
+ let args
194
+ try {
195
+ args = parseArgs(argv, env)
196
+ } catch (err) {
197
+ log(`shelfware: ${describeError(err)}`)
198
+ log(HELP)
199
+ exit(1)
200
+ return null
201
+ }
134
202
  if (args.help) {
135
203
  log(HELP)
136
204
  return null
@@ -140,12 +208,31 @@ export async function launch({
140
208
  exit(1)
141
209
  return null
142
210
  }
143
- const port = args.port ?? await pickPort(DEFAULT_PORT)
144
- env.NUXT_PUBLIC_SHELFWARE_TOKEN = makeToken()
145
- env.NITRO_HOST = HOST
146
- env.NITRO_PORT = String(port)
147
- env.NODE_ENV = 'production'
148
- await importServer()
211
+ if (args.port !== null) {
212
+ const problem = await checkPort(args.port)
213
+ if (problem) {
214
+ log(`shelfware: port ${args.port} ${problem} on ${HOST}; pick another (--port or PORT)`)
215
+ exit(1)
216
+ return null
217
+ }
218
+ }
219
+ // Both of these can fail for reasons the user can act on — every port in the
220
+ // range is taken, or the server throws at import (a Nitro crash, or an
221
+ // EADDRINUSE from losing the race for the auto-picked port). One line, exit 1,
222
+ // never a stack trace.
223
+ let port
224
+ try {
225
+ port = args.port ?? await pickPort(DEFAULT_PORT)
226
+ env[TOKEN_ENV] = makeToken()
227
+ env.NITRO_HOST = HOST
228
+ env.NITRO_PORT = String(port)
229
+ env.NODE_ENV = 'production'
230
+ await importServer()
231
+ } catch (err) {
232
+ log(`shelfware: ${describeError(err)}`)
233
+ exit(1)
234
+ return null
235
+ }
149
236
  if (!(await waitForHealth(port))) {
150
237
  log(`shelfware: the server did not answer on http://${HOST}:${port} within 10 s`)
151
238
  exit(1)
@@ -153,6 +240,6 @@ export async function launch({
153
240
  }
154
241
  const url = `http://${HOST}:${port}`
155
242
  log(`shelfware at ${url}`)
156
- if (args.open) openBrowser(url, { spawn })
243
+ if (args.open) openBrowser(url, { spawn, env })
157
244
  return { port, url }
158
245
  }
package/bin/shelfware.mjs CHANGED
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { launch } from './launch.mjs'
3
3
 
4
- await launch({
5
- importServer: () => import('../.output/server/index.mjs'),
6
- })
4
+ // launch() reports every failure it can foresee as one line and exits 1; this is
5
+ // the last resort, so an unforeseen throw still never reaches the user as a stack.
6
+ try {
7
+ await launch({ importServer: () => import('../.output/server/index.mjs') })
8
+ } catch (err) {
9
+ console.error(`shelfware: ${err instanceof Error ? err.message : String(err)}`)
10
+ process.exit(1)
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelfware",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "shelfware finds the shelfware in your skill drawers.",
5
5
  "keywords": [
6
6
  "skills",
@@ -37,7 +37,7 @@
37
37
  "dev": "nuxt dev",
38
38
  "build": "nuxt build",
39
39
  "prepare": "nuxt prepare",
40
- "typecheck": "nuxt typecheck",
40
+ "typecheck": "nuxt typecheck && tsc -p tsconfig.unit.json",
41
41
  "test": "vitest run",
42
42
  "test:unit": "vitest run --project unit --passWithNoTests",
43
43
  "test:nuxt": "vitest run --project nuxt --passWithNoTests",
@@ -51,6 +51,7 @@
51
51
  "@nuxt/test-utils": "^4.2.0",
52
52
  "@nuxt/ui": "^4.11.0",
53
53
  "@types/markdown-it": "^14.2.0",
54
+ "@types/node": "^20.19.43",
54
55
  "@vue/test-utils": "^2.5.0",
55
56
  "@vueuse/core": "^14.4.0",
56
57
  "happy-dom": "^20.14.0",
@@ -63,6 +64,9 @@
63
64
  "yaml": "^2.9.0"
64
65
  },
65
66
  "pnpm": {
66
- "onlyBuiltDependencies": ["esbuild", "vue-demi"]
67
+ "onlyBuiltDependencies": [
68
+ "esbuild",
69
+ "vue-demi"
70
+ ]
67
71
  }
68
72
  }