paneltir 0.6.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.
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The kit's command line: `paneltir init`, `paneltir version`, `paneltir check`.
4
+ *
5
+ * The panel is two halves that install differently. The components are a
6
+ * library, so they arrive in node_modules and are never edited. The gate — the
7
+ * password, the session, the write-back to GitHub — is not: it holds this
8
+ * project's password, points at this project's repository, and runs on the
9
+ * hosting platform rather than in the bundle. Shipping it inside the library
10
+ * would mean every project's front door moving when the library updates, which
11
+ * is the opposite of what a front door is for.
12
+ *
13
+ * So `init` copies those files into the project once. From then on they belong
14
+ * to the project: readable, editable, in its own git history. Re-running never
15
+ * overwrites what is already there.
16
+ */
17
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
18
+ import { dirname, join, relative, resolve } from 'node:path'
19
+ import { fileURLToPath, pathToFileURL } from 'node:url'
20
+
21
+ const here = dirname(fileURLToPath(import.meta.url))
22
+ const packageRoot = resolve(here, '..')
23
+ const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'))
24
+
25
+ let fingerprint = { hash: 'unknown', fileCount: 0 }
26
+ try {
27
+ fingerprint = JSON.parse(readFileSync(join(packageRoot, 'fingerprint.json'), 'utf8'))
28
+ } catch {
29
+ /* installed without it; version alone is still useful */
30
+ }
31
+
32
+ const shortHash = fingerprint.hash.replace('sha256:', '').slice(0, 12)
33
+
34
+ /** Every file under a directory, as paths relative to it. */
35
+ function walk(dir, base = dir) {
36
+ const out = []
37
+ for (const name of readdirSync(dir)) {
38
+ const full = join(dir, name)
39
+ if (statSync(full).isDirectory()) out.push(...walk(full, base))
40
+ else out.push(relative(base, full))
41
+ }
42
+ return out
43
+ }
44
+
45
+ /**
46
+ * What this project already has.
47
+ *
48
+ * A project installing this is rarely empty: it usually has a dashboard, a
49
+ * palette, and often a password already in use. Asking it to invent all three
50
+ * again is how a migration produces a second admin area nobody retires and a
51
+ * second password nobody remembers. So look first, and say what was found.
52
+ *
53
+ * Nothing here reads a secret's value — only whether a name appears — and
54
+ * nothing is written from what is found.
55
+ */
56
+ function survey(target) {
57
+ const found = { password: [], panels: [], palettes: [], kits: [] }
58
+
59
+ const readIf = (file) => {
60
+ try {
61
+ return readFileSync(join(target, file), 'utf8')
62
+ } catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ // A password already in use, under whatever name this project gave it.
68
+ const PASSWORD_NAMES = /^\s*(?:export\s+)?([A-Z0-9_]*(?:ADMIN|PANEL|DASHBOARD|BOARD)[A-Z0-9_]*(?:PASSWORD|PASS|SECRET|TOKEN)[A-Z0-9_]*)\s*=/gm
69
+ for (const file of ['.env', '.env.local', '.env.production', '.env.example', '.env.development']) {
70
+ const body = readIf(file)
71
+ if (!body) continue
72
+ for (const match of body.matchAll(PASSWORD_NAMES)) {
73
+ found.password.push({ file, name: match[1] })
74
+ }
75
+ }
76
+
77
+ // An admin area already built.
78
+ for (const dir of ['src/admin', 'src/dashboard', 'app/admin', 'app/dashboard', 'pages/admin', 'src/pages/admin', 'admin']) {
79
+ try {
80
+ if (statSync(join(target, dir)).isDirectory()) found.panels.push(dir)
81
+ } catch {
82
+ /* not there */
83
+ }
84
+ }
85
+
86
+ // A palette already chosen — the thing to adopt before drawing anything.
87
+ for (const file of [
88
+ 'tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.cjs',
89
+ 'src/theme.ts', 'src/theme.tsx', 'src/styles/theme.css', 'src/theme/index.ts',
90
+ 'src/styles/tokens.css', 'src/index.css', 'src/App.css', 'styles/globals.css',
91
+ ]) {
92
+ if (readIf(file) !== null) found.palettes.push(file)
93
+ }
94
+
95
+ // Another dashboard kit still installed.
96
+ const pkgJson = readIf('package.json')
97
+ if (pkgJson) {
98
+ try {
99
+ const deps = { ...JSON.parse(pkgJson).dependencies, ...JSON.parse(pkgJson).devDependencies }
100
+ for (const name of Object.keys(deps || {})) {
101
+ if (/(dashboard|admin|tremor|refine|react-admin)/i.test(name) && name !== 'paneltir') {
102
+ found.kits.push(name)
103
+ }
104
+ }
105
+ } catch {
106
+ /* unreadable package.json is not this command's problem */
107
+ }
108
+ }
109
+
110
+ return found
111
+ }
112
+
113
+ function reportSurvey(found) {
114
+ const lines = []
115
+
116
+ if (found.palettes.length) {
117
+ lines.push(`This project already has a palette. Adopt it before drawing anything:
118
+ ${found.palettes.slice(0, 4).join('\n ')}
119
+ Map those colours onto DashboardThemeTokens rather than inventing new ones —
120
+ a dashboard in colours nobody chose is the first thing people ask to change.`)
121
+ } else {
122
+ lines.push(`No palette found. Do not invent one: the kit ships midnight, oldMoney and
123
+ cyberpunk as starting points, and INSTALL.md says how to pick.`)
124
+ }
125
+
126
+ if (found.password.length) {
127
+ const names = [...new Set(found.password.map((p) => p.name))]
128
+ lines.push(`This project already has a password: ${names.join(', ')}
129
+ (in ${[...new Set(found.password.map((p) => p.file))].join(', ')})
130
+ Reuse it. Either rename it to ADMIN_PASSWORD, or edit lib/session.ts to read
131
+ the name you already use. Do not add a second password — two passwords for one
132
+ admin area is one password nobody can remember and one nobody rotates.`)
133
+ }
134
+
135
+ if (found.panels.length) {
136
+ lines.push(`There is already an admin area here: ${found.panels.join(', ')}
137
+ Build the new board inside it rather than beside it, and delete what it
138
+ replaces in the same change. An old panel left running is one still reachable.`)
139
+ }
140
+
141
+ if (found.kits.length) {
142
+ lines.push(`Another dashboard package is still installed: ${found.kits.join(', ')}
143
+ Remove it once the board is moved over, or the project ships both.`)
144
+ }
145
+
146
+ return lines
147
+ }
148
+
149
+ /** The board. Never replaced once it exists — see the loop below. */
150
+ const BOARD_SEED = 'src/data/panel-state.json'
151
+
152
+ function init(args) {
153
+ const force = args.includes('--force')
154
+ const target = process.cwd()
155
+ const templateDir = join(packageRoot, 'template')
156
+
157
+ if (!existsSync(templateDir)) {
158
+ console.error('paneltir: this install has no template/ directory.')
159
+ process.exit(1)
160
+ }
161
+
162
+ const files = walk(templateDir).sort()
163
+ const written = []
164
+ const kept = []
165
+
166
+ for (const file of files) {
167
+ const from = join(templateDir, file)
168
+ const to = join(target, file)
169
+ // --force replaces the gate, which is code and can be re-copied. It never
170
+ // touches the board: that file stops being a template the moment the
171
+ // project writes its first card into it, and overwriting somebody's board
172
+ // because they asked to refresh their middleware is unforgivable.
173
+ const protectedFile = file === BOARD_SEED
174
+ if (existsSync(to) && (!force || protectedFile)) {
175
+ kept.push(file)
176
+ continue
177
+ }
178
+ mkdirSync(dirname(to), { recursive: true })
179
+ cpSync(from, to)
180
+ written.push(file)
181
+ }
182
+
183
+ console.log(`paneltir ${pkg.version} — the panel's gate, into ${target}\n`)
184
+
185
+ const notes = reportSurvey(survey(target))
186
+ if (notes.length) {
187
+ console.log('What this project already has\n')
188
+ for (const note of notes) console.log(` ${note.replace(/\n/g, '\n ')}\n`)
189
+ }
190
+
191
+ if (written.length) {
192
+ console.log('Written:')
193
+ for (const file of written) console.log(` + ${file}`)
194
+ }
195
+ if (kept.length) {
196
+ console.log(`${written.length ? '\n' : ''}Left alone (already yours${force ? '' : '; --force to replace'}):`)
197
+ for (const file of kept) console.log(` = ${file}`)
198
+ }
199
+
200
+ if (!written.length) {
201
+ console.log('\nNothing to do — every file is already in place.')
202
+ return
203
+ }
204
+
205
+ console.log(`
206
+ These files are yours now. They are not part of the library and will not
207
+ change when it updates: read them, edit them, commit them.
208
+
209
+ src/data/panel-state.json your board. Empty, with the columns and
210
+ three questions nobody has answered yet.
211
+ \`init --force\` never overwrites it.
212
+ .claude/skills/panel/SKILL.md how Claude reads and writes that board:
213
+ what an order means, which taps are
214
+ refusals, and what moving a card claims.
215
+ Say \`/panel\` in a session to use it.
216
+
217
+ Set these where the project is hosted, never in the repository:
218
+
219
+ ADMIN_PASSWORD the panel password. Required — without it the panel
220
+ answers 503 rather than becoming public.
221
+ GH_TOKEN a fine-grained token with contents: read and write on
222
+ THIS project's repository only.
223
+ PANEL_REPO this project's "owner/repo".
224
+ PANEL_FILE where the board lives in it, if not src/data/panel-state.json.
225
+ SESSION_SECRET optional. Defaults to ADMIN_PASSWORD, so changing the
226
+ password ends every session already issued.
227
+
228
+ Then check two things before trusting it:
229
+
230
+ 1. Opening /admin signed out lands on /login, not on the panel.
231
+ 2. The panel's own JavaScript bundle answers 401 signed out. If it does
232
+ not, adjust the last entry of the matcher in middleware.ts to wherever
233
+ this project's build emits it — protecting the page but not the bundle
234
+ leaves the board readable by anyone who opens the HTML.
235
+ `)
236
+ }
237
+
238
+ function version() {
239
+ console.log(`paneltir v${pkg.version}`)
240
+ console.log(`fingerprint ${fingerprint.hash}`)
241
+ console.log(`${fingerprint.fileCount} tracked files`)
242
+ console.log(`installed from github:daifukus/paneltir#v${pkg.version}`)
243
+ }
244
+
245
+ /**
246
+ * Answers "is this project on the version it thinks it is?" without anyone
247
+ * reading node_modules — the question every dashboard needs to answer before
248
+ * a bug report is worth anything.
249
+ */
250
+ function check(args) {
251
+ const expected = args.find((a) => !a.startsWith('-'))
252
+ if (!expected) {
253
+ console.log(`paneltir v${pkg.version} · ${shortHash}`)
254
+ console.log(`\nPass a version to compare against, e.g. paneltir check ${pkg.version}`)
255
+ return
256
+ }
257
+ const want = expected.replace(/^v/, '')
258
+ if (want === pkg.version) {
259
+ console.log(`ok — paneltir v${pkg.version} · ${shortHash}`)
260
+ return
261
+ }
262
+ console.error(`paneltir is v${pkg.version}, expected v${want}`)
263
+ console.error(`\nInstall the one you meant:\n npm install github:daifukus/paneltir#v${want}`)
264
+ process.exit(1)
265
+ }
266
+
267
+ /**
268
+ * Reads this project's board and says what is wrong with it.
269
+ *
270
+ * The panel refuses to save a broken board and CI refuses to ship one, but
271
+ * neither helps the person who has just edited the file by hand and wants to
272
+ * know before they push. This is that answer, from the same check.
273
+ */
274
+ function board(args) {
275
+ const file = args.find((a) => !a.startsWith('-')) || process.env.PANEL_FILE || 'src/data/panel-state.json'
276
+ const path = resolve(process.cwd(), file)
277
+
278
+ if (!existsSync(path)) {
279
+ console.error(`no board at ${file}`)
280
+ console.error('\nPass its path, or run `paneltir init` to write an empty one.')
281
+ process.exit(1)
282
+ }
283
+
284
+ const check = readBoard(readFileSync(path, 'utf8'))
285
+ if (check.ok) {
286
+ const cards = check.board.cards.length
287
+ console.log(`ok ${file} — ${cards} card${cards === 1 ? '' : 's'}, ${check.board.columns.length} columns`)
288
+ return
289
+ }
290
+
291
+ console.error(`${file} cannot be read as a board:\n`)
292
+ for (const problem of check.problems) {
293
+ console.error(problem.at ? ` ${problem.at}\n ${problem.says}` : ` ${problem.says}`)
294
+ }
295
+ if (check.reason === 'version') {
296
+ console.error(`\nThis is not damage: the board is written for another version of the kit.`)
297
+ } else {
298
+ console.error(`\nThe file is in git. \`git diff ${file}\` shows what changed.`)
299
+ }
300
+ process.exit(1)
301
+ }
302
+
303
+ const { readBoard } = await import(pathToFileURL(join(here, '../dist/index.js')).href)
304
+
305
+ const [command, ...rest] = process.argv.slice(2)
306
+
307
+ switch (command) {
308
+ case 'init':
309
+ init(rest)
310
+ break
311
+ case 'version':
312
+ version()
313
+ break
314
+ case 'check':
315
+ check(rest)
316
+ break
317
+ case 'board':
318
+ board(rest)
319
+ break
320
+ default:
321
+ console.log(`paneltir v${pkg.version} · ${shortHash}
322
+
323
+ paneltir init [--force] copy the panel's gate into this project
324
+ paneltir version what is installed, and where it came from
325
+ paneltir check [version] fail if the installed version is not the one meant
326
+ paneltir board [file] read this project's board and say what is wrong
327
+
328
+ The components install as a library and are never edited. The gate — password,
329
+ session, write-back — is copied in by init and belongs to the project.`)
330
+ if (command) process.exit(1)
331
+ }