funoteka 0.1.0

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 (162) hide show
  1. package/.env.example +90 -0
  2. package/CHANGELOG.md +104 -0
  3. package/DEPLOY.md +375 -0
  4. package/LICENSE +21 -0
  5. package/README.md +138 -0
  6. package/deploy/answers.schema.json +151 -0
  7. package/deploy/build.mjs +88 -0
  8. package/deploy/check-publishable.mjs +246 -0
  9. package/deploy/smoke.mjs +681 -0
  10. package/deploy/systemd/funoteka.env.example +41 -0
  11. package/deploy/systemd/funoteka.service +77 -0
  12. package/deploy/windows/funoteka-service.xml +58 -0
  13. package/deploy/windows/install-service.ps1 +158 -0
  14. package/dist/annotation/store.js +67 -0
  15. package/dist/api/admin-export.js +227 -0
  16. package/dist/api/admin-guard.js +181 -0
  17. package/dist/api/admin-library.js +113 -0
  18. package/dist/api/admin.js +1162 -0
  19. package/dist/api/annotation.js +193 -0
  20. package/dist/api/artistinfo.js +140 -0
  21. package/dist/api/audit.js +32 -0
  22. package/dist/api/auth.js +145 -0
  23. package/dist/api/bookmark.js +108 -0
  24. package/dist/api/browse.js +1241 -0
  25. package/dist/api/config-file.js +202 -0
  26. package/dist/api/config.js +285 -0
  27. package/dist/api/cover.js +304 -0
  28. package/dist/api/download.js +112 -0
  29. package/dist/api/envelope.js +162 -0
  30. package/dist/api/extensions.js +43 -0
  31. package/dist/api/genre.js +81 -0
  32. package/dist/api/health.js +35 -0
  33. package/dist/api/history.js +374 -0
  34. package/dist/api/idempotency.js +66 -0
  35. package/dist/api/keys.js +90 -0
  36. package/dist/api/log-file.js +65 -0
  37. package/dist/api/meta.js +1410 -0
  38. package/dist/api/playlist.js +311 -0
  39. package/dist/api/rescan.js +196 -0
  40. package/dist/api/roots.js +161 -0
  41. package/dist/api/router.js +133 -0
  42. package/dist/api/scanner.js +163 -0
  43. package/dist/api/search.js +82 -0
  44. package/dist/api/server.js +643 -0
  45. package/dist/api/settings.js +67 -0
  46. package/dist/api/stream.js +442 -0
  47. package/dist/api/stubs.js +183 -0
  48. package/dist/api/transcode.js +678 -0
  49. package/dist/api/user.js +73 -0
  50. package/dist/api/virtual.js +362 -0
  51. package/dist/api/visibility.js +21 -0
  52. package/dist/artist/apply.js +752 -0
  53. package/dist/artist/credit.js +95 -0
  54. package/dist/artist/folder.js +65 -0
  55. package/dist/artist/name.js +205 -0
  56. package/dist/artist/nfo.js +101 -0
  57. package/dist/artist/translit.js +79 -0
  58. package/dist/bookmark/store.js +54 -0
  59. package/dist/classify/classify.js +405 -0
  60. package/dist/classify/collision-name.js +109 -0
  61. package/dist/classify/folder-name.js +466 -0
  62. package/dist/classify/roles.js +277 -0
  63. package/dist/classify/shelf-name.js +178 -0
  64. package/dist/classify/tree.js +43 -0
  65. package/dist/cli/args.js +169 -0
  66. package/dist/cli/daemon.js +192 -0
  67. package/dist/cli/entry.js +25 -0
  68. package/dist/cli/keys.js +52 -0
  69. package/dist/cli.js +638 -0
  70. package/dist/cover/pick.js +58 -0
  71. package/dist/cover/picture.js +111 -0
  72. package/dist/cue/engine.js +759 -0
  73. package/dist/cue/match.js +142 -0
  74. package/dist/cue/parse.js +182 -0
  75. package/dist/cue/plan.js +286 -0
  76. package/dist/cue/track-name.js +120 -0
  77. package/dist/db/index.js +163 -0
  78. package/dist/db/issue.js +73 -0
  79. package/dist/db/ledger.js +84 -0
  80. package/dist/db/migrations/001_init.sql +196 -0
  81. package/dist/db/migrations/002_cue_track_flexible.sql +35 -0
  82. package/dist/db/migrations/003_cue_metadata.sql +14 -0
  83. package/dist/db/migrations/004_cue_performer.sql +12 -0
  84. package/dist/db/migrations/005_incremental.sql +23 -0
  85. package/dist/db/migrations/006_tags.sql +57 -0
  86. package/dist/db/migrations/007_artist_credit.sql +35 -0
  87. package/dist/db/migrations/008_issue_stage.sql +39 -0
  88. package/dist/db/migrations/009_cue_title.sql +20 -0
  89. package/dist/db/migrations/010_duration_source.sql +31 -0
  90. package/dist/db/migrations/011_track_title_source.sql +28 -0
  91. package/dist/db/migrations/012_file_container.sql +25 -0
  92. package/dist/db/migrations/013_cover_art.sql +28 -0
  93. package/dist/db/migrations/014_album_year.sql +17 -0
  94. package/dist/db/migrations/015_probe_method.sql +14 -0
  95. package/dist/db/migrations/016_tags_method.sql +20 -0
  96. package/dist/db/migrations/017_cover_indirect.sql +23 -0
  97. package/dist/db/migrations/018_release_year.sql +14 -0
  98. package/dist/db/migrations/019_file_tag_name_file_value.sql +20 -0
  99. package/dist/db/migrations/020_analyze_after_index.sql +16 -0
  100. package/dist/db/migrations/021_cue_index_files.sql +15 -0
  101. package/dist/db/migrations/022_sidecar_text.sql +19 -0
  102. package/dist/db/migrations/023_track_credit.sql +23 -0
  103. package/dist/db/migrations/024_playlists.sql +63 -0
  104. package/dist/db/migrations/025_playlist_sequence.sql +27 -0
  105. package/dist/db/migrations/026_playlist_source_file.sql +19 -0
  106. package/dist/db/migrations/027_lookup_indexes.sql +30 -0
  107. package/dist/db/migrations/028_annotations.sql +47 -0
  108. package/dist/db/migrations/029_scan_state_root_run.sql +28 -0
  109. package/dist/db/migrations/030_history.sql +108 -0
  110. package/dist/db/migrations/031_queue_position_and_plays.sql +46 -0
  111. package/dist/db/migrations/032_bookmarks.sql +27 -0
  112. package/dist/db/migrations/033_bookmark_order.sql +14 -0
  113. package/dist/db/migrations/034_junk.sql +33 -0
  114. package/dist/db/migrations/035_api_key.sql +38 -0
  115. package/dist/db/migrations/036_file_tag_first.sql +88 -0
  116. package/dist/db/migrations/037_admin_idempotency.sql +31 -0
  117. package/dist/db/migrations/038_cue_audio_index.sql +24 -0
  118. package/dist/db/sweep.js +109 -0
  119. package/dist/history/store.js +151 -0
  120. package/dist/inventory/inventory.js +294 -0
  121. package/dist/junk/marks.js +143 -0
  122. package/dist/junk/rule.js +93 -0
  123. package/dist/mcp/client.js +48 -0
  124. package/dist/mcp/server.js +175 -0
  125. package/dist/mcp/tools.js +348 -0
  126. package/dist/playlist/files.js +116 -0
  127. package/dist/playlist/import.js +260 -0
  128. package/dist/playlist/store.js +240 -0
  129. package/dist/probe/ffprobe.js +179 -0
  130. package/dist/run.js +120 -0
  131. package/dist/scan/kinds.js +49 -0
  132. package/dist/scan/scan.js +503 -0
  133. package/dist/scan/schedule.js +132 -0
  134. package/dist/scan/settle.js +172 -0
  135. package/dist/scan/walk.js +132 -0
  136. package/dist/search/index.js +41 -0
  137. package/dist/search/query.js +28 -0
  138. package/dist/stream/flac.js +541 -0
  139. package/dist/stream/mpeg.js +179 -0
  140. package/dist/stream/recode.js +393 -0
  141. package/dist/stream/rewrite.js +163 -0
  142. package/dist/stream/segment.js +624 -0
  143. package/dist/tags/apply.js +703 -0
  144. package/dist/tags/encode.js +164 -0
  145. package/dist/tags/first.js +130 -0
  146. package/dist/tags/flac.js +196 -0
  147. package/dist/tags/genres.js +123 -0
  148. package/dist/tags/id3v1.js +209 -0
  149. package/dist/tags/id3v2.js +754 -0
  150. package/dist/tags/mp4.js +534 -0
  151. package/dist/tags/mpeg.js +355 -0
  152. package/dist/tags/ogg.js +463 -0
  153. package/dist/tags/picture.js +71 -0
  154. package/dist/tags/read.js +269 -0
  155. package/dist/tags/types.js +51 -0
  156. package/dist/tags/vorbis-comment.js +155 -0
  157. package/dist/text/encoding.js +384 -0
  158. package/dist/text/site-name.js +37 -0
  159. package/dist/util/child.js +21 -0
  160. package/dist/util/names.js +97 -0
  161. package/funoteka.json.example +27 -0
  162. package/package.json +55 -0
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # funoteka
2
+
3
+ **The Subsonic server a real collection deserves.**
4
+
5
+ **A folder is an album** — not a guess, not a heuristic: the rule. funoteka keys every record on
6
+ its folder path, so three pressings of the same album stay three albums, a box stays a box and its
7
+ discs, and an image file with a cue sheet becomes real, playable tracks. Nothing merged, nothing lost.
8
+
9
+ - **An image file and its cue sheet become real tracks** — cut exactly where the cue's `INDEX`
10
+ says, at the frame it names.
11
+ - **A folder is an album.** Three pressings stay three; a box set stays a box set *and* its
12
+ disc-albums. Tags are metadata, never the identity.
13
+ - **Every format your collection actually has** — FLAC, ALAC, MP3, Ogg Vorbis, Opus — plus
14
+ **transcoding**, so a client that cannot play one still can.
15
+ - **Your files are never modified.** Classification, cue boundaries, encodings, dedup — all of it
16
+ lives in a meta layer beside the server. Point it at your disk; your disk stays as it is.
17
+ - **No LLM, no Discogs, no Last.fm, no external service.** The core reads what is in your files and
18
+ folders. Enrichment is possible later, and it is optional.
19
+ - **An agent installs, configures and runs it.** Hand your agent `DEPLOY.md`.
20
+
21
+ A complete **Subsonic / OpenSubsonic** surface, filled from your own files wherever it can be and
22
+ answered honestly and empty wherever it cannot — so the client you already like just works.
23
+
24
+ ## Why the folder wins
25
+
26
+ Three CD pressings of one album are three different records, and a box set is not one CD. Because
27
+ funoteka keys the album on its **path**, that is exactly what you get:
28
+
29
+ ```
30
+ Cure, The/Pictures Of You [1989 UK CD]/ ← one album
31
+ Cure, The/Pictures Of You [1990 US CD]/ ← another album
32
+ Cure, The/Disintegration (Box, 12 CD)/ ← the box, and each disc inside it
33
+ ```
34
+
35
+ Three albums, a box set of twelve discs. No merging, no guessing, nothing lost.
36
+
37
+ ## What it does
38
+
39
+ | | |
40
+ |---|---|
41
+ | **Identity** | the absolute folder path — not the tag |
42
+ | **Cue sheets** | image + cue → real tracks; several `.cue` in one folder, `INDEX 00`, `mm:ss:ff`, per-track performer |
43
+ | **Box sets** | a release *and* its disc-albums |
44
+ | **Encodings** | CP1251, UTF-16, ID3v1/v2, Vorbis, MP4 — normalised in the meta layer, files untouched |
45
+ | **Rescan** | incremental by mtime/size; a timer, an optional filesystem watcher, or your own scheduler |
46
+ | **Junk** | releaser debris and fake folders are marked and hidden from the default view — never deleted |
47
+ | **Library** | playlists (server-side and `.m3u`), stars and ratings, play history, play queue, bookmarks, offline download |
48
+ | **Clients** | the Subsonic API + OpenSubsonic extensions; everything else answers with honest, well-formed empty responses — never a 404 |
49
+ | **Management** | an admin API on its own port, and an **MCP server** — an agent can run the whole thing |
50
+
51
+ ## Install
52
+
53
+ ### Docker — the main path
54
+
55
+ The image carries everything, **ffmpeg included** — the only external binary the server ever wants,
56
+ and only for cue tracks inside an m4a/MP4 container.
57
+
58
+ ```sh
59
+ git clone https://github.com/kzntsv-dev/funoteka && cd funoteka
60
+ cp .env.example .env # your music folder, a password, an admin token
61
+ docker compose up -d
62
+ docker compose exec funoteka node src/cli.ts scan /music # fill the library, once
63
+ ```
64
+
65
+ > **On Windows with Git Bash**, prefix that last line with `MSYS_NO_PATHCONV=1`. The shell
66
+ > rewrites `/music` into a Windows path before Docker ever sees it, and the scan then answers
67
+ > honestly about an empty root — `/app/C:/Program Files/Git/music` — which reads like a broken
68
+ > image and is really the shell.
69
+
70
+ ### Or let your agent do it
71
+
72
+ > **Give `DEPLOY.md` to your agent.** It asks the questions that matter — where the music is, where
73
+ > to keep the database, which ports, TLS or a reverse proxy, a password — and does the rest. Local
74
+ > machine, NAS, VDS: the guide covers all three.
75
+
76
+ ### Native, without Docker
77
+
78
+ Needs **Node 24+**. There are **no runtime dependencies** — Node carries SQLite and FTS5, and the
79
+ repository runs its own TypeScript, with no build step at all. The published *package* is the one
80
+ place that cannot hold: Node refuses to strip types from anything under `node_modules`
81
+ (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`, and no flag lifts it), so what npm gets is the same
82
+ sources compiled once at release (`npm run build`) — a build that exists for the package and not for
83
+ the repository. `ffmpeg` on the PATH is needed only for cue tracks inside an m4a/MP4 container.
84
+
85
+ ```sh
86
+ npm install -g funoteka
87
+ funoteka scan /path/to/music # build the library
88
+ funoteka serve --daemon # serve it
89
+ ```
90
+
91
+ ## Quick start
92
+
93
+ 1. Start the server. It listens on **4533**.
94
+ 2. Install any Subsonic client — Symfonium, Feishin, Substreamer, or whatever you already use.
95
+ 3. Point it at `http://your-host:4533`, with the login and password you set.
96
+ 4. Browse and play.
97
+
98
+ You see the disk as it should have looked all along: artists, albums, box sets, and cue tracks that
99
+ are tracks.
100
+
101
+ ## How it works
102
+
103
+ ```
104
+ your folders ─► scanner ─► classifier ─► meta layer (SQLite) ─► Subsonic API ─► any client
105
+ │ │ ▲
106
+ cue engine folder identity admin API · MCP
107
+ ```
108
+
109
+ The scanner **reads**. Everything it learns — what is an album, where a cue track starts, what an
110
+ encoding was — goes into a SQLite database it owns, beside the server. Change your mind about a
111
+ classification and you edit that layer, not your music.
112
+
113
+ ## Compatibility
114
+
115
+ - **Subsonic 1.16.1** — the standard surface.
116
+ - **OpenSubsonic** — `getArtistInfo2`, genres, `getAlbumList2` by year and genre, embedded cover
117
+ art, Ogg and ID3 readers, transcoding, and the extension manifest (`getOpenSubsonicExtensions`
118
+ declares exactly what works — nothing more).
119
+ - What the server does not source from your own files — lyrics, similar artists, top songs, bios —
120
+ is answered **honestly and empty**, so no client breaks on it.
121
+
122
+ ## What it is not
123
+
124
+ - **Not a web player.** It is headless: no UI, no dashboard. You drive it with the admin API or MCP,
125
+ and the music reaches you through the Subsonic client you already like.
126
+ - **Not tag-driven.** It will not merge your pressings, rename your artists to match an online
127
+ database, or call home. Nothing about your collection leaves the machine.
128
+ - **Not overclaiming.** Where a thing does not exist yet — a published cross-build, say — the docs
129
+ say so instead of pretending.
130
+
131
+ ## Documentation
132
+
133
+ - `DEPLOY.md` — install, configure and manage, written to be read by an agent
134
+ - The admin API and the MCP server — `DEPLOY.md` §11
135
+
136
+ ## License
137
+
138
+ MIT © Victor Kuznetsov. Use it, fork it, ship it.
@@ -0,0 +1,151 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://funoteka/deploy/answers.schema.json",
4
+ "title": "funoteka deployment answers",
5
+ "description": "The questions DEPLOY.md asks, in a form a program can hold. An agent that has been handed answers instead of asking for them reads this for the shape; an agent that has to ask reads the descriptions, which say why each answer matters and what happens when it is left out. Every property except the music directory and the listener credentials has a default, and the defaults are a working server.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["music", "user", "password"],
9
+ "properties": {
10
+ "target": {
11
+ "description": "Which path of DEPLOY.md to follow. Docker is the main path: one command, and ffmpeg comes inside the image. The native paths are for a machine where Docker is not wanted, and need Node 24 and ffmpeg installed.",
12
+ "enum": ["docker", "linux-service", "windows-service"],
13
+ "default": "docker"
14
+ },
15
+ "host": {
16
+ "description": "The machine this is being deployed to, as the person answering would name it. Used in the steps that have to be run there rather than here; nothing in the deployment depends on it.",
17
+ "type": "string"
18
+ },
19
+
20
+ "music": {
21
+ "description": "Absolute path to the collection, as the SERVER sees it. For the docker target that is the path inside the container (the compose file mounts the host's directory at /music), and for the native targets it is the path on the machine. It is mounted read-only: nothing in this server ever writes to, moves or renames a file of yours.",
22
+ "type": "string"
23
+ },
24
+ "data": {
25
+ "description": "Where the server keeps what is its own: the meta layer (a SQLite file), its re-encode cache, its log, its audit file and the config file. This directory is the whole of what a backup takes, apart from the music itself.",
26
+ "type": "string",
27
+ "default": "./data"
28
+ },
29
+
30
+ "musicPort": {
31
+ "description": "The port music clients connect to. 4533 is Subsonic's own, so a client that guesses one guesses right.",
32
+ "type": "integer",
33
+ "minimum": 1,
34
+ "maximum": 65535,
35
+ "default": 4533
36
+ },
37
+ "adminPort": {
38
+ "description": "The port the admin surface listens on. It is meant to be reachable from outside — that is what it is for — and it is guarded by its own token rather than by not being reachable. One more than the music port, so it is one number to remember.",
39
+ "type": "integer",
40
+ "minimum": 1,
41
+ "maximum": 65535,
42
+ "default": 4534
43
+ },
44
+
45
+ "user": {
46
+ "description": "The listener's name. Subsonic clients send it with every request.",
47
+ "type": "string",
48
+ "minLength": 1
49
+ },
50
+ "password": {
51
+ "description": "The listener's password. The server REFUSES TO START without a password or an api key: a music library on the open network is not 'it came up', it is a misconfiguration.",
52
+ "type": "string",
53
+ "minLength": 1
54
+ },
55
+ "apiKey": {
56
+ "description": "An alternative to the password, for a client that would rather not hold one (OpenSubsonic's apiKeyAuthentication). Either one is enough; both may be set.",
57
+ "type": "string"
58
+ },
59
+ "adminToken": {
60
+ "description": "The admin surface's whole gate, and the admin surface does not exist without it: with no token the port is not open at all. Generate one with `node -e \"console.log(require('crypto').randomBytes(32).toString('base64url'))\"`. It is a credential: keep it out of the music directory and out of git.",
61
+ "type": "string"
62
+ },
63
+ "adminAllow": {
64
+ "description": "Who may reach the admin port at all, as comma-separated addresses and CIDR blocks. Empty means every address, which is what the token is then carrying on its own. A second lock, not the gate.",
65
+ "type": "string",
66
+ "default": ""
67
+ },
68
+ "adminTrustProxy": {
69
+ "description": "Whether X-Forwarded-For may be believed when deciding adminAllow. False unless a proxy is the only way in: the header is written by the caller, so believing it on a directly reachable port means anyone can claim any address and walk through the allowlist.",
70
+ "type": "boolean",
71
+ "default": false
72
+ },
73
+ "tls": {
74
+ "description": "How the admin port is encrypted. 'self' terminates TLS here and needs tlsCert and tlsKey (both or neither; a path that cannot be read stops the server at startup). 'proxy' leaves the port plain and expects a reverse proxy in front of it — not a weaker arrangement, and the one to narrow adminAllow to the proxy's address for. 'none' is for a deployment on a private network.",
75
+ "enum": ["none", "self", "proxy"],
76
+ "default": "none"
77
+ },
78
+ "tlsCert": {
79
+ "description": "Path to the certificate, when tls is 'self'.",
80
+ "type": "string"
81
+ },
82
+ "tlsKey": {
83
+ "description": "Path to its private key, when tls is 'self'.",
84
+ "type": "string"
85
+ },
86
+
87
+ "supervised": {
88
+ "description": "Whether something will start this process again after it exits — a restart policy, a systemd unit, a service wrapper. It is what makes POST /restart a restart rather than a stop, and where it is false the route refuses with a 409 instead of taking the music down. The compose file and the unit set it; a bare `serve --daemon` has nobody and should say so.",
89
+ "type": "boolean",
90
+ "default": true
91
+ },
92
+ "scanInterval": {
93
+ "description": "Minutes between the scans the server starts by itself. Zero turns the timer off, for a deployment with a cron job of its own.",
94
+ "type": "integer",
95
+ "minimum": 0,
96
+ "default": 360
97
+ },
98
+ "scanQuietFrom": {
99
+ "description": "The first hour (0-23, local) in which a scan may not START. A scan is audible — it reads every file — and these are the hours a house is asleep in. A scan that comes due inside the window waits for the end of it rather than being skipped.",
100
+ "type": "integer",
101
+ "minimum": 0,
102
+ "maximum": 23,
103
+ "default": 3
104
+ },
105
+ "scanQuietTo": {
106
+ "description": "The hour the quiet window ends. Equal to scanQuietFrom means no quiet hours at all. The window may cross midnight (23 to 6).",
107
+ "type": "integer",
108
+ "minimum": 0,
109
+ "maximum": 23,
110
+ "default": 6
111
+ },
112
+ "scanWatch": {
113
+ "description": "Watch the roots for changes instead of waiting for the interval. It waits for five seconds of silence first, so a file being copied in is never read halfway. It cannot work on a network share — the server says so in the log and falls back to the interval rather than pretending.",
114
+ "type": "boolean",
115
+ "default": false
116
+ },
117
+
118
+ "logFile": {
119
+ "description": "A file to append this process's own output to, for a deployment whose supervisor collects nothing. Leave it out where Docker, systemd or the service wrapper already collects it.",
120
+ "type": "string",
121
+ "default": ""
122
+ },
123
+ "ffmpeg": {
124
+ "description": "The ffmpeg binary, needed only to cut cue tracks out of an .m4a/MP4 image. The Docker image has one; a native install has to provide it. Without it those tracks are refused with a reason and everything else works.",
125
+ "type": "string",
126
+ "default": "ffmpeg"
127
+ }
128
+ },
129
+ "examples": [
130
+ {
131
+ "target": "docker",
132
+ "music": "/music",
133
+ "data": "./data",
134
+ "user": "you",
135
+ "password": "change-me",
136
+ "adminToken": "…32 random bytes…",
137
+ "supervised": true
138
+ },
139
+ {
140
+ "target": "linux-service",
141
+ "music": "/srv/music",
142
+ "data": "/var/lib/funoteka",
143
+ "user": "you",
144
+ "password": "change-me",
145
+ "adminToken": "…32 random bytes…",
146
+ "adminAllow": "10.0.0.0/8",
147
+ "tls": "proxy",
148
+ "adminTrustProxy": true
149
+ }
150
+ ]
151
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The build, for the npm package and for nothing else.
4
+ *
5
+ * node deploy/build.mjs
6
+ *
7
+ * **The repository runs its own TypeScript and needs no build.** `node
8
+ * src/cli.ts` is the program, `npm test` is the suite, and TypeScript is a
9
+ * dev-time tool — that is a property of this project worth keeping, and this
10
+ * script does not take it away.
11
+ *
12
+ * The *package* is the one place it cannot hold. Node refuses to strip types
13
+ * from any file under `node_modules` (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`),
14
+ * and there is no flag that lifts it — checked on v24.19.0: the default, both
15
+ * `--experimental-strip-types` and `--experimental-transform-types`, and the two
16
+ * together all refuse. So a package whose `bin` names a `.ts` file installs and
17
+ * then cannot start, which is what `npx -y funoteka mcp` would have done. The
18
+ * published artifact therefore carries what `tsc` emits, and this is how it is
19
+ * made.
20
+ *
21
+ * Two steps, because `tsc` emits TypeScript and the program also reads files
22
+ * that are not: the schema migrations live beside the code that applies them
23
+ * (`src/db/migrations`, found through `import.meta.dirname`). Every such file is
24
+ * copied to the same place under `dist` — every one, not the migrations
25
+ * specifically, so that a `.json` added tomorrow travels without anyone
26
+ * remembering to extend this script — and then the copy is *checked* rather than
27
+ * assumed. A build that compiled the code and left an asset behind would produce
28
+ * a package that starts and then fails somewhere deeper, which is the kind of
29
+ * failure that reads as a bug in the program.
30
+ *
31
+ * Exit code 0 on a complete build, 1 otherwise.
32
+ */
33
+
34
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
35
+ import { spawnSync } from 'node:child_process';
36
+ import { dirname, join, relative } from 'node:path';
37
+
38
+ /** Every file under `src` that is not TypeScript, as a path relative to `src`. */
39
+ function assets(dir = 'src') {
40
+ const found = [];
41
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
42
+ const from = join(dir, entry.name);
43
+ if (entry.isDirectory()) {
44
+ found.push(...assets(from));
45
+ continue;
46
+ }
47
+ if (entry.name.endsWith('.ts')) continue;
48
+ found.push(relative('src', from));
49
+ }
50
+ return found.sort();
51
+ }
52
+
53
+ const tsc = join('node_modules', 'typescript', 'bin', 'tsc');
54
+ if (!existsSync(tsc)) {
55
+ console.error('no TypeScript in node_modules — run `npm ci` first');
56
+ process.exit(1);
57
+ }
58
+
59
+ rmSync('dist', { recursive: true, force: true });
60
+
61
+ const compiled = spawnSync(process.execPath, [tsc, '-p', 'tsconfig.build.json'], { stdio: 'inherit' });
62
+ if (compiled.status !== 0) {
63
+ console.error(`tsc exited with ${compiled.status}`);
64
+ process.exit(1);
65
+ }
66
+
67
+ const carried = assets();
68
+ for (const one of carried) {
69
+ const to = join('dist', one);
70
+ mkdirSync(dirname(to), { recursive: true });
71
+ cpSync(join('src', one), to);
72
+ }
73
+
74
+ const entry = join('dist', 'cli.js');
75
+ const entrySize = existsSync(entry) ? statSync(entry).size : 0;
76
+ const missing = carried.filter((one) => !existsSync(join('dist', one)));
77
+ const schema = carried.filter((one) => one.endsWith('.sql'));
78
+
79
+ console.log(`dist/cli.js ${entrySize} bytes, ${carried.length - missing.length}/${carried.length} asset(s) beside it`);
80
+ if (entrySize === 0 || missing.length > 0) {
81
+ for (const one of missing) console.error(` not in dist: ${one}`);
82
+ console.error('the build is not complete: the entry point or a file the program reads is missing');
83
+ process.exit(1);
84
+ }
85
+ if (schema.length === 0) {
86
+ console.error('no schema migrations under src — a package without them could not open a database');
87
+ process.exit(1);
88
+ }
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The publishability gate: what must not be in a copy that leaves this machine.
4
+ *
5
+ * node deploy/check-publishable.mjs [dir] [--shapes-only] # default: the current directory
6
+ *
7
+ * **Run it on the curated copy, not on the development tree.** The development
8
+ * tree keeps its meta deliberately — `.mappa/`, `AGENTS.md`, `.pi/` are tracked
9
+ * there, and the private host lives in `.mappa/config.yaml` on purpose. A gate
10
+ * run against the tree is red every day, and a gate that is red every day is a
11
+ * gate nobody reads. The copy is the artefact; the copy is what is checked.
12
+ *
13
+ * Two questions, and both are about the copy:
14
+ *
15
+ * 1. **private VALUES — zero hits.** By value, not by name: `.mappa` in a
16
+ * `.gitignore` line is not a leak and must not be reported as one, while a
17
+ * hostname, a machine name or the collection's own path is a leak wherever
18
+ * it appears.
19
+ * 2. **meta files in the tree — none**, at any depth, not only at the top.
20
+ *
21
+ * **Which values, and why the list is split in two.** Shapes are named here and
22
+ * are safe to publish: a Windows drive letter, a workstation prefix, the name of
23
+ * a registry product. The *identifiers* of this estate — the private git host,
24
+ * its registry, the leading characters of an administrative token, the operator,
25
+ * the LAN the live server sits on, the NAS marker — are **not** in this file:
26
+ * this file travels to the public repository, and a gate that publishes what it
27
+ * forbids is worse than no gate. They arrive by environment instead:
28
+ *
29
+ * FUNOTEKA_PRIVATE_VALUES='git.example.site,some-handle' node deploy/check-publishable.mjs .
30
+ *
31
+ * CI supplies them from a repository secret, so the public workflow carries the
32
+ * check and not the values.
33
+ *
34
+ * **The identifiers are required, not optional.** Without them the gate knows
35
+ * three shapes and nothing else, and a green verdict would mean far less than it
36
+ * looks like it means — so it exits 2 and says so. `--shapes-only` is the
37
+ * explicit way to ask for the reduced check, and it is a way to say "I know this
38
+ * is weaker", not a default.
39
+ *
40
+ * **One file is exempt from the shape scan and not from the rest:** this one,
41
+ * wherever a copy of this repository keeps it (`deploy/check-publishable.mjs`)
42
+ * and whichever copy is the one running. It names the shapes it forbids, so it
43
+ * matches itself, and a gate that reports itself as a leak is a gate that gets
44
+ * switched off. The exemption is by that path and by this file's own resolved
45
+ * path, so it holds whoever runs it and from wherever — and it covers the built-in
46
+ * shapes only: anything arriving through `FUNOTEKA_PRIVATE_VALUES` is checked here
47
+ * too, which is what keeps this file honest about the only values that would
48
+ * matter if they appeared in it.
49
+ *
50
+ * Exit code: 0 clean, 1 findings, 2 misused (bad directory, or identifiers
51
+ * missing without `--shapes-only`). A gate that cannot fail would be a report,
52
+ * and a green report is not a check.
53
+ */
54
+
55
+ import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
56
+ import { basename, join, relative, resolve, sep } from 'node:path';
57
+
58
+ const argv = process.argv.slice(2);
59
+ const shapesOnly = argv.includes('--shapes-only');
60
+ const target = resolve(argv.find((argument) => !argument.startsWith('--')) ?? '.');
61
+
62
+ try {
63
+ if (!statSync(target).isDirectory()) throw new Error('not a directory');
64
+ } catch (error) {
65
+ console.error(`cannot read ${target}: ${error.message}`);
66
+ process.exit(2);
67
+ }
68
+
69
+ /**
70
+ * Shapes: generic markers, safe to name in public, and each one a leak wherever
71
+ * it appears.
72
+ *
73
+ * **A private IPv4 range is deliberately NOT one of them.** `10.0.0.0/8` is
74
+ * what every page about an address allowlist says, including this project's own
75
+ * documentation and its tests — a rule against private ranges flags the
76
+ * documentation for documenting the feature, and 57 such lines is how a gate
77
+ * gets switched off. `music/` is absent for the same reason: `/music` is the
78
+ * container's mount point and is public by design.
79
+ *
80
+ * So what stays here is what is generic, and what names *this* estate goes to
81
+ * `FUNOTEKA_PRIVATE_VALUES` — including the LAN the live server sits on, which
82
+ * is an identifier of this estate and not a shape.
83
+ */
84
+ const SHAPES = [
85
+ { pattern: /\bZ:\\/, why: 'the collection drive' },
86
+ { pattern: /DESKTOP-/, why: 'a workstation name' },
87
+ { pattern: /verdaccio/, why: 'a private registry' },
88
+ ];
89
+
90
+ /**
91
+ * Identifiers of this estate, supplied from outside (see the header). Each is
92
+ * matched literally after escaping, **unanchored and case-sensitive**: an
93
+ * unanchored substring is the strict reading — a value inside a longer word is
94
+ * still the value — and the price of that strictness is that the values must be
95
+ * specific. A one- or two-character value would match half the tree, so short
96
+ * ones are reported as a warning rather than quietly trusted.
97
+ */
98
+ const supplied = (() => {
99
+ const raw = process.env.FUNOTEKA_PRIVATE_VALUES ?? '';
100
+ return raw
101
+ .split(',')
102
+ .map((value) => value.trim())
103
+ .filter((value) => value !== '')
104
+ .map((value) => ({ pattern: new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), why: 'supplied as private' }));
105
+ })();
106
+
107
+ if (supplied.length === 0 && !shapesOnly) {
108
+ console.error('the estate identifiers were not supplied — set FUNOTEKA_PRIVATE_VALUES, or pass --shapes-only');
109
+ console.error(' (--shapes-only checks three generic shapes and no hostname, handle or token, and says so in its verdict)');
110
+ process.exit(2);
111
+ }
112
+ for (const { pattern } of supplied) {
113
+ const value = pattern.source.replace(/\\(.)/g, '$1');
114
+ if (value.length < 4) console.warn(` warning: supplied value ${JSON.stringify(value)} is very short — expect false positives`);
115
+ }
116
+
117
+ /**
118
+ * Meta that belongs to the development tree, by name or by directory anywhere in
119
+ * the tree. `README.ru.md` is here because the public README is English and the
120
+ * Russian one is a development note that is not in the allowlist. `.env` and
121
+ * `funoteka.json` are not meta, they are somebody's machine — a copy made from a
122
+ * working checkout would otherwise carry credentials or a port — so the report
123
+ * heading says "meta and machine-local files" rather than calling them meta.
124
+ */
125
+ const META_FILES = ['AGENTS.md', 'CLAUDE.md', '.mappa-manifest.json', 'README.ru.md', '.env', 'funoteka.json'];
126
+ const META_DIRS = ['.mappa', '.pi', '.wiki', '.tasks'];
127
+
128
+ /** A file this large is read in one piece; past the cap it is named, not read. */
129
+ const MAX_BYTES = 16 * 1024 * 1024;
130
+
131
+ const selfPath = realpathSync(import.meta.filename);
132
+ const selfRelative = 'deploy/check-publishable.mjs';
133
+
134
+ const rules = [
135
+ ...SHAPES.map(({ pattern, why }) => ({ pattern, why, selfExempt: true })),
136
+ ...supplied.map(({ pattern, why }) => ({ pattern, why, selfExempt: false })),
137
+ ];
138
+ const hits = rules.map(() => []);
139
+ const skipped = { large: 0, other: 0 };
140
+ const meta = new Set();
141
+
142
+ function* walk(dir) {
143
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
144
+ const path = join(dir, entry.name);
145
+ if (entry.isDirectory()) {
146
+ yield path;
147
+ if (entry.name === '.git' || entry.name === 'node_modules' || META_DIRS.includes(entry.name)) continue;
148
+ yield* walk(path);
149
+ } else if (entry.isFile()) {
150
+ yield path;
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Both readings of a file, because a private value does not care how the bytes
157
+ * were encoded: UTF-8 lossy (which is what a PNG's text chunk or a PDF reads as)
158
+ * and, when there are NUL bytes, UTF-16 as well — a `.env`-shaped file saved by
159
+ * a Windows editor is UTF-16 and would otherwise be skipped as "binary".
160
+ */
161
+ function textsOf(path) {
162
+ const bytes = readFileSync(path);
163
+ const texts = [bytes.toString('utf8')];
164
+ if (bytes.includes(0)) texts.push(bytes.toString('utf16le'));
165
+ return texts;
166
+ }
167
+
168
+ const targetPrefix = target.endsWith(sep) ? target : target + sep;
169
+ let files = 0;
170
+
171
+ try {
172
+ for (const path of walk(target)) {
173
+ const relativePath = relative(target, path).split(sep).join('/');
174
+ const parts = relativePath.split('/');
175
+ const metaDir = parts.findIndex((part) => META_DIRS.includes(part));
176
+ if (metaDir >= 0) meta.add(parts.slice(0, metaDir + 1).join('/'));
177
+ else if (META_FILES.includes(basename(relativePath))) meta.add(relativePath);
178
+
179
+ if (metaDir >= 0 || !statSync(path).isFile()) continue;
180
+
181
+ const stats = statSync(path);
182
+ if (stats.size > MAX_BYTES) {
183
+ skipped.large += 1;
184
+ continue;
185
+ }
186
+ if (realpathSync(path) === selfPath || relativePath === selfRelative) {
187
+ // Scanned only for the supplied identifiers; see the header.
188
+ const text = readFileSync(path, 'utf8');
189
+ text.split('\n').forEach((line, index) => {
190
+ rules.forEach((rule, position) => {
191
+ if (rule.selfExempt) return;
192
+ const match = rule.pattern.exec(line);
193
+ if (match) hits[position].push(`${relativePath}:${index + 1}: ${line.slice(Math.max(0, match.index - 40), match.index + 60)}`);
194
+ });
195
+ });
196
+ files += 1;
197
+ continue;
198
+ }
199
+
200
+ files += 1;
201
+ for (const text of textsOf(path)) {
202
+ text.split('\n').forEach((line, index) => {
203
+ rules.forEach((rule, position) => {
204
+ const match = rule.pattern.exec(line);
205
+ if (match) hits[position].push(`${relativePath}:${index + 1}: ${line.slice(Math.max(0, match.index - 40), match.index + 60)}`);
206
+ });
207
+ });
208
+ }
209
+ }
210
+ } catch (error) {
211
+ console.error(`the walk stopped: ${error.message}`);
212
+ process.exit(2);
213
+ }
214
+
215
+ const shown = relative(process.cwd(), target) || basename(target);
216
+ console.log(`funoteka publishability gate — ${shown === '' ? basename(target) : shown}`);
217
+ console.log(` ${files} text file(s) read`);
218
+ if (skipped.large > 0) console.log(` ${skipped.large} file(s) skipped: larger than ${MAX_BYTES} bytes`);
219
+ console.log(
220
+ supplied.length === 0
221
+ ? ' identifiers: SHAPES ONLY — requested with --shapes-only, not a full check'
222
+ : ` identifiers: ${supplied.length} supplied via FUNOTEKA_PRIVATE_VALUES`,
223
+ );
224
+ console.log();
225
+
226
+ let findings = 0;
227
+ console.log('private values (by value, not by meta name):');
228
+ for (const [position, rule] of rules.entries()) {
229
+ const found = hits[position];
230
+ if (found.length === 0) continue;
231
+ findings += 1;
232
+ console.log(` ✗ ${rule.pattern} — ${rule.why}: ${found.length} line(s)`);
233
+ for (const line of found.slice(0, 5)) console.log(` ${line}`);
234
+ if (found.length > 5) console.log(` … and ${found.length - 5} more`);
235
+ }
236
+ if (findings === 0) console.log(' ✓ zero hits');
237
+
238
+ console.log('\nmeta and machine-local files in the tree:');
239
+ if (meta.size === 0) console.log(' ✓ none');
240
+ else {
241
+ findings += meta.size;
242
+ for (const name of [...meta].sort()) console.log(` ✗ ${name}`);
243
+ }
244
+
245
+ console.log(`\nverdict: ${findings === 0 ? 'publishable' : `NOT publishable — ${findings} finding(s)`}`);
246
+ process.exit(findings === 0 ? 0 : 1);