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.
- package/CLAUDE_MD_SNIPPET.md +66 -0
- package/INSTALL.md +261 -0
- package/LICENSE +90 -0
- package/README.md +404 -0
- package/bin/paneltir.mjs +331 -0
- package/dist/index.d.ts +1489 -0
- package/dist/index.js +3754 -0
- package/dist/style.css +1973 -0
- package/dist/style.d.ts +2 -0
- package/fingerprint.json +7 -0
- package/package.json +94 -0
- package/renovate.json.example +13 -0
- package/template/.claude/skills/panel/SKILL.md +155 -0
- package/template/api/login.ts +63 -0
- package/template/api/logout.ts +31 -0
- package/template/api/panel-state.ts +317 -0
- package/template/lib/session.ts +170 -0
- package/template/middleware.ts +175 -0
- package/template/src/data/panel-state.json +83 -0
package/bin/paneltir.mjs
ADDED
|
@@ -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
|
+
}
|