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.
- package/.output/nitro.json +1 -1
- package/.output/public/_nuxt/{BZuUK3cr.js → Bzmuhhqm.js} +1 -1
- package/.output/public/_nuxt/{6IstVYrV.js → CJmYvhe6.js} +1 -1
- package/.output/public/_nuxt/CMk5jOCS.js +1 -0
- package/.output/public/_nuxt/DJQqUjJu.js +2 -0
- package/.output/public/_nuxt/DfJzPSBi.js +1 -0
- package/.output/public/_nuxt/EBA28doP.js +14 -0
- package/.output/public/_nuxt/builds/latest.json +1 -1
- package/.output/public/_nuxt/builds/meta/bc384f67-a2f8-4b6b-9aa1-cb68fb93d366.json +1 -0
- package/.output/public/_nuxt/entry.CKteCcdN.css +2 -0
- package/.output/public/_nuxt/error-404.1qC5mjOI.css +1 -0
- package/.output/public/_nuxt/error-500.BcOmuN7K.css +1 -0
- package/.output/public/_nuxt/rWsrwf2L.js +36 -0
- package/.output/public/_nuxt/{C_VgD9ZI.js → sK9Xse3o.js} +1 -1
- package/.output/server/chunks/nitro/nitro.mjs +102 -102
- package/.output/server/chunks/routes/renderer.mjs +1 -1
- package/.output/server/chunks/virtual/precomputed.mjs +1 -1
- package/.output/server/package.json +1 -1
- package/README.md +11 -1
- package/bin/launch.d.mts +54 -0
- package/bin/launch.mjs +111 -24
- package/bin/shelfware.mjs +8 -3
- package/package.json +7 -3
- package/.output/public/_nuxt/9C5WUlLE.js +0 -36
- package/.output/public/_nuxt/BXXtFil5.js +0 -1
- package/.output/public/_nuxt/BeLyMmd4.js +0 -1
- package/.output/public/_nuxt/DB5Vxbte.js +0 -2
- package/.output/public/_nuxt/DMg0j7gv.js +0 -14
- package/.output/public/_nuxt/builds/meta/4c307968-4837-497b-a570-d6ee336accc4.json +0 -1
- package/.output/public/_nuxt/entry.b3_DJTEt.css +0 -2
- package/.output/public/_nuxt/error-404.CHsn7XF-.css +0 -1
- 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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
46
|
-
if (port
|
|
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
|
-
|
|
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',
|
|
57
|
-
server.listen({ port, host }, () => server.close(() => resolve(
|
|
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
|
|
127
|
+
await sleep(intervalMs, signal)
|
|
95
128
|
}
|
|
96
129
|
return false
|
|
97
130
|
}
|
|
98
131
|
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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.
|
|
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": [
|
|
67
|
+
"onlyBuiltDependencies": [
|
|
68
|
+
"esbuild",
|
|
69
|
+
"vue-demi"
|
|
70
|
+
]
|
|
67
71
|
}
|
|
68
72
|
}
|