@pikku/skills 0.12.25 → 0.12.27
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/CHANGELOG.md +43 -0
- package/dist/skills.gen.js +1 -1
- package/package.json +1 -1
- package/skills/pikku-a11y/SKILL.md +59 -0
- package/skills/pikku-admin-to-fabric/SKILL.md +212 -0
- package/skills/pikku-architect/SKILL.md +1 -0
- package/skills/pikku-auth/references/better-auth.md +125 -7
- package/skills/pikku-blueprint-to-fabric/SKILL.md +377 -0
- package/skills/pikku-blueprint-to-fabric/scripts/inventory.mjs +367 -0
- package/skills/pikku-build/SKILL.md +1 -0
- package/skills/pikku-build/references/app.md +1 -1
- package/skills/pikku-build/references/multi-app.md +55 -0
- package/skills/pikku-build/references/ship.md +2 -2
- package/skills/pikku-fabric/SKILL.md +35 -18
- package/skills/pikku-i18n/SKILL.md +5 -3
- package/skills/pikku-knowledge/SKILL.md +1 -0
- package/skills/pikku-list-query/SKILL.md +163 -0
- package/skills/pikku-permissions/SKILL.md +126 -0
- package/skills/pikku-react/references/client.md +20 -0
- package/skills/pikku-realtime/SKILL.md +147 -0
- package/skills/pikku-seo/SKILL.md +133 -0
- package/skills/pikku-software-archaeology/SKILL.md +1 -0
- package/skills/pikku-webhook/SKILL.md +25 -0
- package/skills/pikku-workflow/SKILL.md +37 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Emit the Pikku implementation inventory for a .knowledge/ blueprint.
|
|
3
|
+
//
|
|
4
|
+
// node inventory.mjs <path-to-.knowledge> [--json] [--domain <Name>]
|
|
5
|
+
//
|
|
6
|
+
// Answers "what will actually be built?" BEFORE any code exists: every pikkuFunc,
|
|
7
|
+
// permission, scheduler, queue worker, workflow, HTTP wiring, event channel,
|
|
8
|
+
// table and scenario — plus, crucially, what is BLOCKED by an unresolved decision
|
|
9
|
+
// and what is deliberately NOT built.
|
|
10
|
+
//
|
|
11
|
+
// This is a projection of the blueprint, not a plan you write by hand. It is
|
|
12
|
+
// derived, so it stays honest: if it says 187 functions, the blueprint says 187.
|
|
13
|
+
|
|
14
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
const dir = process.argv[2]
|
|
18
|
+
const asJson = process.argv.includes('--json')
|
|
19
|
+
const domainArg = process.argv.includes('--domain')
|
|
20
|
+
? process.argv[process.argv.indexOf('--domain') + 1]
|
|
21
|
+
: null
|
|
22
|
+
|
|
23
|
+
if (!dir) {
|
|
24
|
+
console.error('usage: inventory.mjs <path-to-.knowledge> [--json] [--domain <Name>]')
|
|
25
|
+
process.exit(2)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The four files pikku-software-archaeology's schema marks `x-optional` — an
|
|
29
|
+
// API-only app really has no frontend. Every other file is REQUIRED, and a
|
|
30
|
+
// missing or unparseable one is fatal rather than an empty list: this script's
|
|
31
|
+
// whole claim is that its counts come from the blueprint, so silently reporting
|
|
32
|
+
// "0 commands" for a directory that isn't a blueprint tells the exact lie the
|
|
33
|
+
// inventory exists to prevent.
|
|
34
|
+
const OPTIONAL = new Set([
|
|
35
|
+
'interfaces.json',
|
|
36
|
+
'frontend.json',
|
|
37
|
+
'frontend-routes.json',
|
|
38
|
+
'frontend-components.json',
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
const fatal = (msg) => {
|
|
42
|
+
console.error(`inventory: ${msg}`)
|
|
43
|
+
process.exit(1)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!existsSync(dir)) fatal(`no such directory: ${dir}`)
|
|
47
|
+
|
|
48
|
+
const read = (f) => {
|
|
49
|
+
const p = join(dir, f)
|
|
50
|
+
if (!existsSync(p)) {
|
|
51
|
+
if (OPTIONAL.has(f)) return null
|
|
52
|
+
fatal(
|
|
53
|
+
`${dir} is missing ${f}. Run the archaeology validator first — ` +
|
|
54
|
+
`an incomplete blueprint gives a count that reads as complete.`
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(readFileSync(p, 'utf-8'))
|
|
59
|
+
} catch (e) {
|
|
60
|
+
fatal(`${f} is not valid JSON: ${e.message}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A required file that parsed but carries the wrong shape is the same lie. */
|
|
65
|
+
const list = (f, key) => {
|
|
66
|
+
const doc = read(f)
|
|
67
|
+
if (doc === null) return []
|
|
68
|
+
const rows = doc[key]
|
|
69
|
+
if (rows === undefined) fatal(`${f} has no "${key}" array — blueprint shape changed?`)
|
|
70
|
+
if (!Array.isArray(rows)) fatal(`${f}: "${key}" is ${typeof rows}, expected an array`)
|
|
71
|
+
return rows
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const domains = list('domains.json', 'domains')
|
|
75
|
+
const entities = list('entities.json', 'entities')
|
|
76
|
+
const commands = list('commands.json', 'commands')
|
|
77
|
+
const queries = list('queries.json', 'queries')
|
|
78
|
+
const events = list('events.json', 'events')
|
|
79
|
+
const policies = list('policies.json', 'policies')
|
|
80
|
+
const workflows = list('workflows.json', 'workflows')
|
|
81
|
+
const apiDoc = read('api.json')
|
|
82
|
+
const api = apiDoc.surfaces ?? apiDoc.api ?? fatal('api.json has neither "surfaces" nor "api"')
|
|
83
|
+
const integrations = list('integrations.json', 'integrations')
|
|
84
|
+
const migration = read('migration.json')
|
|
85
|
+
const interfaces = read('interfaces.json')?.interfaces ?? []
|
|
86
|
+
const feComponents = read('frontend-components.json')?.components ?? []
|
|
87
|
+
const feRoutes = read('frontend-routes.json')?.routes ?? []
|
|
88
|
+
|
|
89
|
+
// ---- the decisions gate -----------------------------------------------------
|
|
90
|
+
// A concept named in an unresolved decision's blockedConcepts cannot be built.
|
|
91
|
+
//
|
|
92
|
+
// Answers are recorded OUTSIDE the blueprint — in the rebuild's own knowledge
|
|
93
|
+
// base — because the blueprint is a record of the LEGACY app and must not be
|
|
94
|
+
// rewritten as the rebuild proceeds. So pass the ones already answered:
|
|
95
|
+
//
|
|
96
|
+
// --resolved 1,2,3 (1-based index into migration.json.decisionsNeeded)
|
|
97
|
+
//
|
|
98
|
+
// Anything not listed is still blocking.
|
|
99
|
+
const allDecisions = migration.decisionsNeeded ?? []
|
|
100
|
+
const resolvedArg = process.argv.includes('--resolved')
|
|
101
|
+
? process.argv[process.argv.indexOf('--resolved') + 1]
|
|
102
|
+
: ''
|
|
103
|
+
const resolvedIdx = new Set(
|
|
104
|
+
resolvedArg.split(',').map((s) => parseInt(s.trim(), 10)).filter(Boolean)
|
|
105
|
+
)
|
|
106
|
+
const decisions = allDecisions.filter((_, i) => !resolvedIdx.has(i + 1))
|
|
107
|
+
const resolved = allDecisions.filter((_, i) => resolvedIdx.has(i + 1))
|
|
108
|
+
const blocked = new Map() // concept -> question
|
|
109
|
+
for (const d of decisions) {
|
|
110
|
+
for (const c of d.blockedConcepts ?? []) {
|
|
111
|
+
if (!blocked.has(c)) blocked.set(c, d.question)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const dropped = migration.dropped ?? []
|
|
115
|
+
const droppedNames = new Set(
|
|
116
|
+
dropped.map((d) => (d.path ?? d.file ?? '').split('/').pop())
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const isBlocked = (name, domain) => blocked.has(name) || blocked.has(domain)
|
|
120
|
+
|
|
121
|
+
// ---- classification ---------------------------------------------------------
|
|
122
|
+
// Cron-ish triggers. The blueprint records schedules in prose ("daily at 05:00",
|
|
123
|
+
// "every minute"), because that is how the legacy code expressed them.
|
|
124
|
+
const CRON = /(cron|daily|nightly|hourly|every minute|every \d|schedule|at \d{2}:\d{2})/i
|
|
125
|
+
const QUEUE = /(queue|consumer|perform_later|sidekiq|worker|async)/i
|
|
126
|
+
const WEBHOOK = /(webhook|POST \/webhooks)/i
|
|
127
|
+
// A trigger that fires off a row write is the legacy shape of an EVENT, not of a
|
|
128
|
+
// workflow — a handler doing five unrelated things because there was no bus.
|
|
129
|
+
// These become an event + its consumers, which is the structural upgrade.
|
|
130
|
+
const EVENT = /(after_commit|after_save|on create\/update\/destroy|observer|callback|mirror)/i
|
|
131
|
+
|
|
132
|
+
const classifyWorkflow = (w) => {
|
|
133
|
+
const t = `${w.name} ${w.trigger ?? ''}`
|
|
134
|
+
if (w.kind === 'system') {
|
|
135
|
+
if (WEBHOOK.test(t)) return 'wireHTTP + command (webhook ingress)'
|
|
136
|
+
if (CRON.test(t)) return 'wireScheduler'
|
|
137
|
+
if (EVENT.test(t)) return 'event consumer (realtime/queue)'
|
|
138
|
+
if (QUEUE.test(t)) return 'wireQueueWorker'
|
|
139
|
+
return 'pikkuWorkflowFunc'
|
|
140
|
+
}
|
|
141
|
+
// User and admin journeys are NOT pikku workflows — they are sequences of
|
|
142
|
+
// commands a person drives through the UI. They become scenarios.
|
|
143
|
+
return 'scenario (user journey)'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// HTTP is warranted ONLY where the caller is a system we do not control and
|
|
147
|
+
// cannot ask to speak RPC — i.e. it POSTs to a URL we publish, on its schedule.
|
|
148
|
+
// In practice that means inbound webhooks.
|
|
149
|
+
//
|
|
150
|
+
// Everything else is RPC, including surfaces that look like they need HTTP:
|
|
151
|
+
// - `auth: none` means anonymous, not external. Our own sign-up page calls it.
|
|
152
|
+
// - a tokened/capability URL (a check-in link, a public share link) is a
|
|
153
|
+
// first-party page reading a token; the page can call RPC like any other.
|
|
154
|
+
// The caller being ours is what decides this, not the URL's shape or its auth.
|
|
155
|
+
const needsHttp = (s) => /webhook/i.test(s.path ?? '')
|
|
156
|
+
|
|
157
|
+
const byDomain = (arr) => {
|
|
158
|
+
const m = new Map()
|
|
159
|
+
for (const x of arr) {
|
|
160
|
+
const d = x.domain ?? 'unassigned'
|
|
161
|
+
if (!m.has(d)) m.set(d, [])
|
|
162
|
+
m.get(d).push(x)
|
|
163
|
+
}
|
|
164
|
+
return m
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const cmdByDomain = byDomain(commands)
|
|
168
|
+
const qryByDomain = byDomain(queries)
|
|
169
|
+
const polByDomain = byDomain(policies)
|
|
170
|
+
const evtByDomain = byDomain(events)
|
|
171
|
+
const entByDomain = byDomain(entities)
|
|
172
|
+
|
|
173
|
+
const schedulers = workflows.filter((w) => classifyWorkflow(w) === 'wireScheduler')
|
|
174
|
+
const queueWorkers = workflows.filter((w) => classifyWorkflow(w) === 'wireQueueWorker')
|
|
175
|
+
const ingress = workflows.filter((w) => classifyWorkflow(w).startsWith('wireHTTP'))
|
|
176
|
+
const eventConsumers = workflows.filter((w) => classifyWorkflow(w) === 'event consumer (realtime/queue)')
|
|
177
|
+
const pikkuWorkflows = workflows.filter((w) => classifyWorkflow(w) === 'pikkuWorkflowFunc')
|
|
178
|
+
const journeys = workflows.filter((w) => classifyWorkflow(w) === 'scenario (user journey)')
|
|
179
|
+
const httpSurfaces = api.filter(needsHttp)
|
|
180
|
+
const scenarioCount = workflows.reduce((n, w) => n + (w.scenarios ?? []).length, 0)
|
|
181
|
+
|
|
182
|
+
const blockedCommands = commands.filter((c) => isBlocked(c.name, c.domain))
|
|
183
|
+
const blockedQueries = queries.filter((q) => isBlocked(q.name, q.domain))
|
|
184
|
+
const customLogic = feComponents.filter((c) => c.rebuild === 'custom-logic')
|
|
185
|
+
const cheapComponents = feComponents.filter((c) => c.rebuild !== 'custom-logic')
|
|
186
|
+
|
|
187
|
+
// ---- addons -----------------------------------------------------------------
|
|
188
|
+
// An addon is warranted where a capability is self-contained, reused across
|
|
189
|
+
// domains, and has a clear service seam — which is what a `hard`/`critical`
|
|
190
|
+
// integration is. Advisory: the call is the operator's.
|
|
191
|
+
const addonCandidates = integrations.filter(
|
|
192
|
+
(i) => i.importance === 'critical' || i.replacementDifficulty === 'hard'
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if (asJson) {
|
|
196
|
+
console.log(
|
|
197
|
+
JSON.stringify(
|
|
198
|
+
{
|
|
199
|
+
totals: {
|
|
200
|
+
pikkuFunc: commands.length,
|
|
201
|
+
pikkuSessionlessFuncReadonly: queries.length,
|
|
202
|
+
pikkuPermission: policies.length,
|
|
203
|
+
wireScheduler: schedulers.length,
|
|
204
|
+
wireQueueWorker: queueWorkers.length,
|
|
205
|
+
webhookIngress: ingress.length,
|
|
206
|
+
pikkuWorkflowFunc: pikkuWorkflows.length,
|
|
207
|
+
wireHTTP: httpSurfaces.length,
|
|
208
|
+
eventChannels: events.length,
|
|
209
|
+
tables: entities.length,
|
|
210
|
+
scenarios: scenarioCount,
|
|
211
|
+
blockedCommands: blockedCommands.length,
|
|
212
|
+
blockedQueries: blockedQueries.length,
|
|
213
|
+
droppedArtifacts: dropped.length,
|
|
214
|
+
customLogicComponents: customLogic.length,
|
|
215
|
+
},
|
|
216
|
+
blocked: [...blocked.entries()].map(([concept, question]) => ({ concept, question })),
|
|
217
|
+
},
|
|
218
|
+
null,
|
|
219
|
+
2
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
process.exit(0)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- report -----------------------------------------------------------------
|
|
226
|
+
const out = []
|
|
227
|
+
const p = (s = '') => out.push(s)
|
|
228
|
+
|
|
229
|
+
p('# Pikku implementation inventory')
|
|
230
|
+
p('')
|
|
231
|
+
p(`Derived from \`${dir}\`. Every number below is a projection of the blueprint —`)
|
|
232
|
+
p('nothing here is estimated or invented.')
|
|
233
|
+
p('')
|
|
234
|
+
|
|
235
|
+
p('## Totals')
|
|
236
|
+
p('')
|
|
237
|
+
p('| Pikku artifact | Count | From |')
|
|
238
|
+
p('|---|---:|---|')
|
|
239
|
+
p(`| \`pikkuFunc\` (state-changing) | ${commands.length} | \`commands.json\` |`)
|
|
240
|
+
p(`| \`pikkuSessionlessFunc\` + \`readonly: true\` | ${queries.length} | \`queries.json\` |`)
|
|
241
|
+
p(`| \`pikkuPermission\` | ${policies.length} | \`policies.json\` |`)
|
|
242
|
+
p(`| \`wireScheduler\` | ${schedulers.length} | \`workflows.json\` (system + cron) |`)
|
|
243
|
+
p(`| \`wireQueueWorker\` | ${queueWorkers.length} | \`workflows.json\` (system + queue) |`)
|
|
244
|
+
p(`| webhook ingress (\`wireHTTP\` + command) | ${ingress.length} | \`workflows.json\` (system + webhook) |`)
|
|
245
|
+
p(`| \`pikkuWorkflowFunc\` | ${pikkuWorkflows.length} | \`workflows.json\` (system, multi-step) |`)
|
|
246
|
+
p(`| \`wireHTTP\` (fixed external URLs only) | ${httpSurfaces.length} of ${api.length} surfaces | \`api.json\` |`)
|
|
247
|
+
p(`| event channels (realtime/queue) | ${events.length} | \`events.json\` |`)
|
|
248
|
+
p(`| tables | ${entities.length} | \`entities.json\` |`)
|
|
249
|
+
p(`| scenarios | ${scenarioCount} | \`workflows.json[].scenarios\` |`)
|
|
250
|
+
p('')
|
|
251
|
+
p(`**Not built:** ${dropped.length} dropped artifacts · **Blocked:** ${blockedCommands.length + blockedQueries.length} concepts behind ${decisions.length} open decisions.`)
|
|
252
|
+
p('')
|
|
253
|
+
|
|
254
|
+
if (resolved.length) {
|
|
255
|
+
p('## Decisions already taken')
|
|
256
|
+
p('')
|
|
257
|
+
p('Answered at the gate and recorded in the rebuild\'s knowledge base. Each is a')
|
|
258
|
+
p('**deliberate behaviour change** and belongs in the parity report.')
|
|
259
|
+
p('')
|
|
260
|
+
for (const d of resolved) p(`- ${(d.blockedConcepts ?? []).join(', ')} — _${d.question.split('.')[0]}._`)
|
|
261
|
+
p('')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
p('## Blocked by an open decision')
|
|
265
|
+
p('')
|
|
266
|
+
if (decisions.length === 0) {
|
|
267
|
+
p('_None — the gate is clear._')
|
|
268
|
+
} else {
|
|
269
|
+
p('These cannot be built until the question is answered. They block **their own')
|
|
270
|
+
p('domains only** — every other slice proceeds.')
|
|
271
|
+
p('')
|
|
272
|
+
for (const d of decisions) {
|
|
273
|
+
p(`- **${(d.blockedConcepts ?? []).join(', ') || '(unscoped)'}**`)
|
|
274
|
+
p(` <br>${d.question}`)
|
|
275
|
+
if (d.options) p(` <br>_Options:_ ${d.options.join(' · ')}`)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
p('')
|
|
279
|
+
|
|
280
|
+
p('## Scheduled tasks (`wireScheduler`)')
|
|
281
|
+
p('')
|
|
282
|
+
p('| Workflow | Trigger |')
|
|
283
|
+
p('|---|---|')
|
|
284
|
+
for (const w of schedulers) p(`| ${w.name} | ${(w.trigger ?? '').replace(/\|/g, '\\|')} |`)
|
|
285
|
+
p('')
|
|
286
|
+
|
|
287
|
+
if (queueWorkers.length || ingress.length || pikkuWorkflows.length || eventConsumers.length) {
|
|
288
|
+
p('## Other system wiring')
|
|
289
|
+
p('')
|
|
290
|
+
p('| Workflow | Becomes |')
|
|
291
|
+
p('|---|---|')
|
|
292
|
+
for (const w of [...ingress, ...queueWorkers, ...eventConsumers, ...pikkuWorkflows])
|
|
293
|
+
p(`| ${w.name} | \`${classifyWorkflow(w)}\` |`)
|
|
294
|
+
p('')
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
p('## Journeys → scenarios')
|
|
298
|
+
p('')
|
|
299
|
+
p(`${journeys.length} user/admin journeys carrying ${scenarioCount} scenarios excavated from the`)
|
|
300
|
+
p('legacy test suite. These are **not** `pikkuWorkflowFunc`s — they are sequences a')
|
|
301
|
+
p('person drives through the UI, and they become scenario tests.')
|
|
302
|
+
p('')
|
|
303
|
+
p('| Journey | Kind | Scenarios |')
|
|
304
|
+
p('|---|---|---:|')
|
|
305
|
+
for (const w of journeys) p(`| ${w.name} | ${w.kind} | ${(w.scenarios ?? []).length} |`)
|
|
306
|
+
p('')
|
|
307
|
+
|
|
308
|
+
p('## Per domain')
|
|
309
|
+
p('')
|
|
310
|
+
p('| Domain | Tables | `pikkuFunc` | readonly | permissions | events | blocked |')
|
|
311
|
+
p('|---|---:|---:|---:|---:|---:|---:|')
|
|
312
|
+
for (const d of domains) {
|
|
313
|
+
const n = d.name
|
|
314
|
+
const b = (cmdByDomain.get(n) ?? []).filter((c) => isBlocked(c.name, n)).length +
|
|
315
|
+
(qryByDomain.get(n) ?? []).filter((q) => isBlocked(q.name, n)).length
|
|
316
|
+
p(
|
|
317
|
+
`| ${n} | ${(entByDomain.get(n) ?? []).length} | ${(cmdByDomain.get(n) ?? []).length} | ${(qryByDomain.get(n) ?? []).length} | ${(polByDomain.get(n) ?? []).length} | ${(evtByDomain.get(n) ?? []).length} | ${b || ''} |`
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
p('')
|
|
321
|
+
|
|
322
|
+
p('## Addon candidates')
|
|
323
|
+
p('')
|
|
324
|
+
p('Advisory. A capability earns an addon when it is self-contained, reused across')
|
|
325
|
+
p('domains, and has a clear service seam — which is what a critical/hard-to-replace')
|
|
326
|
+
p('integration usually is. The call is yours.')
|
|
327
|
+
p('')
|
|
328
|
+
p('| Integration | Importance | Replace |')
|
|
329
|
+
p('|---|---|---|')
|
|
330
|
+
for (const i of addonCandidates)
|
|
331
|
+
p(`| ${i.name} | ${i.importance ?? ''} | ${i.replacementDifficulty ?? ''} |`)
|
|
332
|
+
p('')
|
|
333
|
+
|
|
334
|
+
if (feComponents.length) {
|
|
335
|
+
p('## Frontend')
|
|
336
|
+
p('')
|
|
337
|
+
p(`- **${cheapComponents.length}** components are a cheap re-expression in Mantine (standard / composition / restyle).`)
|
|
338
|
+
p(`- **${customLogic.length}** carry \`custom-logic\` and must be **ported**. This is the real frontend project.`)
|
|
339
|
+
p(`- **${feRoutes.length}** routes → TanStack routes; their \`dataFrom\` names are already the function names above.`)
|
|
340
|
+
p('')
|
|
341
|
+
if (customLogic.length) {
|
|
342
|
+
p('| custom-logic component | What must survive |')
|
|
343
|
+
p('|---|---|')
|
|
344
|
+
for (const c of customLogic)
|
|
345
|
+
p(`| ${c.name} | ${(c.customLogic ?? '').replace(/\|/g, '\\|').slice(0, 110)} |`)
|
|
346
|
+
p('')
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (interfaces.length) {
|
|
351
|
+
p('## Interfaces')
|
|
352
|
+
p('')
|
|
353
|
+
p('| Channel | Audience | Status |')
|
|
354
|
+
p('|---|---|---|')
|
|
355
|
+
for (const i of interfaces) p(`| ${i.kind} | ${i.audience ?? ''} | ${i.status ?? ''} |`)
|
|
356
|
+
p('')
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
p('## Deliberately not built')
|
|
360
|
+
p('')
|
|
361
|
+
p('| Artifact | Why |')
|
|
362
|
+
p('|---|---|')
|
|
363
|
+
for (const d of dropped)
|
|
364
|
+
p(`| \`${d.path ?? d.file}\` | ${(d.reason ?? '').replace(/\|/g, '\\|').slice(0, 120)} |`)
|
|
365
|
+
p('')
|
|
366
|
+
|
|
367
|
+
console.log(out.join('\n'))
|
|
@@ -12,6 +12,7 @@ description: >-
|
|
|
12
12
|
or wants one specific surface explained rather than built (use that surface's skill).
|
|
13
13
|
allowed-tools: Bash(yarn pikku meta *), Bash(yarn pikku all *), Bash(yarn tsc), Bash(git status *), Bash(git diff *), Bash(git switch *), Bash(git checkout *), Bash(git checkout -b *), Bash(git add *), Bash(git commit *), Bash(git rm *), Bash(git mv *), Bash(git log *), Bash(git branch *), Bash(yarn pikku fabric report *), Bash(npx --no pikku fabric report *)
|
|
14
14
|
argument-hint: '[feature description]'
|
|
15
|
+
installGroups: [core]
|
|
15
16
|
---
|
|
16
17
|
|
|
17
18
|
# Build on Pikku
|
|
@@ -265,7 +265,7 @@ the database still holds that code no longer declares — both need §0's bootst
|
|
|
265
265
|
to have run, and both are worth a look once it has.
|
|
266
266
|
|
|
267
267
|
**One warning about the scaffold's own notes:** `knowledge/index.md` may claim
|
|
268
|
-
the people live in `pikku.config.json`, put there by a
|
|
268
|
+
the people live in `pikku.config.json`, put there by a persona command.
|
|
269
269
|
That is stale. In this template they live in `personas.ts` as above, and
|
|
270
270
|
`pikku.config.json` carries no personas key at all — only `scenarios.emailDomain`.
|
|
271
271
|
Trust the file you can read over the note describing it.
|
|
@@ -8,6 +8,61 @@ leaves `pikkufabric.config.json` pointing at an app nobody has designed yet.
|
|
|
8
8
|
If the split is "one app with paths", you never need this file — add route
|
|
9
9
|
segments under `/app` and give each audience its own entries in `useNavItems()`.
|
|
10
10
|
|
|
11
|
+
## Deciding it is two apps, not one
|
|
12
|
+
|
|
13
|
+
The split you are acting on should already be recorded, but this is the reasoning
|
|
14
|
+
behind it — and the place people get it wrong is the third case at the bottom.
|
|
15
|
+
|
|
16
|
+
**A group that comes in through its own front door gets its own app.** A role
|
|
17
|
+
*inside* an app is not that: it changes which nav items and which buttons a person
|
|
18
|
+
sees, and lives in `useNavItems()` and the `permissions` on the function, not in a
|
|
19
|
+
route subtree.
|
|
20
|
+
|
|
21
|
+
**The test is which side of the counter they are on.** Colleagues share one app and
|
|
22
|
+
differ by nav — the mechanic, the person on the counter, the bookkeeper. Someone
|
|
23
|
+
across the counter with an account of their own gets their own — the customer, the
|
|
24
|
+
tenant, the patient. One app is a real answer and often the right one.
|
|
25
|
+
|
|
26
|
+
**The asymmetry that forces a split is sign-up.** Where staff accounts are created
|
|
27
|
+
*for* people and customers create their own, the two need different sign-up, different
|
|
28
|
+
onboarding and different session shape, and bending one app around both costs more
|
|
29
|
+
than the second app does. Do not collapse two audiences into one app to save a build.
|
|
30
|
+
|
|
31
|
+
Never invent a person the notes do not name in order to reach two.
|
|
32
|
+
|
|
33
|
+
### The group that never signs in
|
|
34
|
+
|
|
35
|
+
Some people use the product with no account at all — ordering from a menu, booking a
|
|
36
|
+
table, opening an invitation. They are not a third case, and **they do not get their
|
|
37
|
+
own frontend**: an app is built around the personas who sign into it. What they get is
|
|
38
|
+
the public route space every app already has.
|
|
39
|
+
|
|
40
|
+
- **`/app/*` is the signed-in application.** One `beforeLoad` on `/app` bounces a
|
|
41
|
+
signed-out visitor to the login. There is no per-route exception.
|
|
42
|
+
- **Every other route is public** — `/`, `/menu`, `/book`, `/r/$code`. No gate, no
|
|
43
|
+
session.
|
|
44
|
+
- **`/` is a landing page and you must write it.** A starter that forwards `/` to
|
|
45
|
+
`/app` does so only because it ships no homepage. Leave the forward in and the
|
|
46
|
+
product's front door is a sign-in form: the anonymous visitor arrives at a login it
|
|
47
|
+
has no account for and never reaches the thing it came for — **while every check
|
|
48
|
+
still passes**, because everything that looks at the app signs in first. This is the
|
|
49
|
+
failure this section exists for.
|
|
50
|
+
|
|
51
|
+
So a screen whose users have no account goes at `/menu`, never `/app/menu`.
|
|
52
|
+
|
|
53
|
+
### The frontend guard is UX and proves nothing
|
|
54
|
+
|
|
55
|
+
The bundle is on the origin and the nav is a client-side decision; anyone can read
|
|
56
|
+
both. The security boundary is the `permissions` field on the function — see
|
|
57
|
+
pikku-permissions. Hiding a nav item keeps people out of screens that would confuse
|
|
58
|
+
them; it never protects data. Never let a hidden UI be the only thing between a user
|
|
59
|
+
and someone else's record: if the invoices nav item is hidden but `listAllInvoices`
|
|
60
|
+
has no `permissions`, the app is wide open and the nav is decoration.
|
|
61
|
+
|
|
62
|
+
Worth a scenario each, because they are two different claims: that a mechanic cannot
|
|
63
|
+
*see* the invoices nav item, and that their call to an invoices RPC is *refused*. The
|
|
64
|
+
second is the one that catches a `permissions` field nobody wired.
|
|
65
|
+
|
|
11
66
|
## The clone
|
|
12
67
|
|
|
13
68
|
```bash
|
|
@@ -74,8 +74,8 @@ Everything above is open source. This is the contract that keeps
|
|
|
74
74
|
- **`pikkufabric.config.json` describes reality.** Every app has an entry with
|
|
75
75
|
the right `cwd`, `port`, `kind` and `dev.command`; exactly one is `primary`;
|
|
76
76
|
`serves` and `personas` name real personas from the personas section. Leave `projectId` as
|
|
77
|
-
`__PROJECT_ID__` — that placeholder means "unlinked", and
|
|
78
|
-
the real one. Do not invent a value to make it look configured.
|
|
77
|
+
`__PROJECT_ID__` — that placeholder means "unlinked", and linking the project
|
|
78
|
+
writes the real one. Do not invent a value to make it look configured.
|
|
79
79
|
- **One `definePersonas` call**, every persona reachable through exactly one
|
|
80
80
|
frontend. Fabric materialises these as its virtual users; a persona nobody
|
|
81
81
|
serves imports as a person with no way in.
|
|
@@ -291,24 +291,40 @@ reload).
|
|
|
291
291
|
pikku fabric login # opens a browser; needs a human, wait for it
|
|
292
292
|
pikku fabric init https://github.com/<owner>/<repo>
|
|
293
293
|
pikku fabric validate # must pass clean
|
|
294
|
-
pikku fabric deploy apply --production
|
|
294
|
+
pikku fabric deploy apply --production -y
|
|
295
295
|
```
|
|
296
296
|
|
|
297
|
+
The branch is positional and defaults to the checked-out one, and `-y` is the
|
|
298
|
+
short form of `--auto-approve`, so a one-shot deploy is:
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
pikku fabric deploy apply -y # the branch you are standing on
|
|
302
|
+
pikku fabric deploy apply my-branch -y # a named one
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`-y` answers the prompts and nothing more. It does **not** approve migrations
|
|
306
|
+
that drop or rewrite data — that stays `--allow-destructive`, typed out on
|
|
307
|
+
purpose.
|
|
308
|
+
|
|
309
|
+
Inferring the branch is safe because the git safety check refuses any branch
|
|
310
|
+
without an upstream or out of sync with it, so it cannot ship an unpushed
|
|
311
|
+
commit; the branch it picked is printed before the build starts. A detached
|
|
312
|
+
HEAD is refused by name rather than travelling on as a branch called `HEAD`.
|
|
313
|
+
|
|
297
314
|
There is no `deploy plan` subcommand — `apply` runs the same auth, git-safety
|
|
298
315
|
and ref resolution itself, and fabric produces the real plan server-side.
|
|
299
316
|
|
|
300
317
|
`apply` confirms before deploying, and with no TTY to ask — CI, an agent shell —
|
|
301
|
-
it refuses rather than hangs. `--auto-approve` supplies that confirmation;
|
|
302
|
-
it only when a human is at a real terminal.
|
|
318
|
+
it refuses rather than hangs. `--auto-approve` (`-y`) supplies that confirmation;
|
|
319
|
+
drop it only when a human is at a real terminal.
|
|
303
320
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
running in CI:
|
|
321
|
+
`apply` waits for a terminal state and exits non-zero unless the deployment went
|
|
322
|
+
live. `--detach` opts out — it queues the deploy, prints the deployment id and
|
|
323
|
+
returns 0, which tells you nothing about whether it worked:
|
|
308
324
|
|
|
309
325
|
| exit | meaning |
|
|
310
326
|
| ---- | ----------------------------------------------------------------------- |
|
|
311
|
-
| 0 | live (or queued,
|
|
327
|
+
| 0 | live (or queued, under `--detach`) |
|
|
312
328
|
| 1 | the command could not run — not logged in, unsafe git state, bad flags |
|
|
313
329
|
| 2 | the deployment failed, errored, timed out server-side, or was cancelled |
|
|
314
330
|
| 3 | the deployment is blocked and nothing the CLI can do will unblock it |
|
|
@@ -318,14 +334,14 @@ Fabric parks every deploy at a gate after the plan phase (`status: suspended`).
|
|
|
318
334
|
Why it parked is the whole story, and it is `statusReason`, not `status`:
|
|
319
335
|
|
|
320
336
|
- `awaiting_approval` — the plan is fine, a human has to publish it.
|
|
321
|
-
|
|
337
|
+
`-y` does that; without it you get exit 3 and the command to run.
|
|
322
338
|
One exception: if fabric marked any pending migration **destructive** — a
|
|
323
|
-
drop, a truncate, a rewrite —
|
|
339
|
+
drop, a truncate, a rewrite — `-y` alone declines and exits 3,
|
|
324
340
|
because a standing yes was given before anyone knew the plan dropped a table.
|
|
325
341
|
The CLI lists the migrations and fabric's reasons; `--allow-destructive`
|
|
326
|
-
accepts them for that deploy.
|
|
342
|
+
accepts them for that deploy, and `-y` implies it.
|
|
327
343
|
- `needs_config` — a declared secret or variable has no value covering the
|
|
328
|
-
stage. The CLI names them.
|
|
344
|
+
stage. The CLI names them. `-y` will **not** force this through;
|
|
329
345
|
set the values and re-attach — `pikku fabric secrets set <name>` for a
|
|
330
346
|
declared secret, `pikku fabric variables set <name> --value <v>` for a declared
|
|
331
347
|
variable. They are separate stores: a secret is sealed to the stage and cannot
|
|
@@ -334,24 +350,25 @@ Why it parked is the whole story, and it is `statusReason`, not `status`:
|
|
|
334
350
|
stage exactly as it is from `.env`, and `--value '"true"'` is the string.
|
|
335
351
|
- `needs_attention` — the plan is red. Nothing to approve.
|
|
336
352
|
|
|
337
|
-
|
|
353
|
+
The wait defaults to a 900s ceiling; `--timeout <seconds>` moves it. On timeout
|
|
338
354
|
it prints the deployment id and the re-attach command rather than lying about
|
|
339
355
|
the outcome.
|
|
340
356
|
|
|
341
357
|
Splitting kick-off from waiting across two CI jobs is the reason
|
|
342
|
-
`--deployment-id` exists
|
|
358
|
+
`--deployment-id` exists, and what `--detach` is for — the first job here has to
|
|
359
|
+
return the id and exit rather than wait:
|
|
343
360
|
|
|
344
361
|
```bash
|
|
345
|
-
id=$(pikku fabric deploy apply --production
|
|
362
|
+
id=$(pikku fabric deploy apply --production -y --detach --json | jq -r 'select(.event=="result").deploymentId')
|
|
346
363
|
# …later, in another job…
|
|
347
|
-
pikku fabric deploy apply --deployment-id "$id"
|
|
364
|
+
pikku fabric deploy apply --deployment-id "$id" -y
|
|
348
365
|
```
|
|
349
366
|
|
|
350
367
|
`--deployment-id` skips the git safety check entirely (the deployment already
|
|
351
368
|
pins a sha, and the checkout is allowed to have moved on) and refuses to be
|
|
352
|
-
combined with `--
|
|
369
|
+
combined with a branch or `--production`, which would let the two disagree.
|
|
353
370
|
|
|
354
|
-
Under `--json`,
|
|
371
|
+
Under `--json`, the wait emits one NDJSON event per line — `created`/`attached`,
|
|
355
372
|
`status` on each transition, `blocked`, `approved` — and the last line is the
|
|
356
373
|
terminal result object, tagged `"event": "result"`.
|
|
357
374
|
|
|
@@ -63,9 +63,11 @@ is what makes an RTL language just another locale file.
|
|
|
63
63
|
- **Do not `asI18n()` a hardcoded English string.** `asI18n` exists to pass
|
|
64
64
|
opaque server data (a name, a slug, an id) through the i18n gate. An enum value
|
|
65
65
|
goes through its generated label map.
|
|
66
|
-
- **Do not wrap `m
|
|
67
|
-
|
|
68
|
-
per-message tree-shaking.
|
|
66
|
+
- **Do not wrap `m` without a reason you can name.** `m.some__key()` already
|
|
67
|
+
satisfies the `I18nNode` gate, so a plain re-export module adds nothing and
|
|
68
|
+
costs per-message tree-shaking. Wrapping the namespace is only worth it when it
|
|
69
|
+
buys a feature the gate cannot — debug masking of translated copy, say — and
|
|
70
|
+
then the catalogue has to be small enough to ship whole.
|
|
69
71
|
- **Do not translate message keys.** `auth__login__title` stays English in
|
|
70
72
|
`de.json`; only the value changes.
|
|
71
73
|
- **Do not edit or commit `src/paraglide/`, `i18n-enum.gen.ts` or `enums.gen.ts`.**
|
|
@@ -13,6 +13,7 @@ description: >-
|
|
|
13
13
|
brief to record. DO NOT TRIGGER when: user asks what
|
|
14
14
|
functions, routes, tables or permissions exist (that is `pikku meta` / `pikku info`, never a
|
|
15
15
|
note), or to write a scenario test (use pikku-scenario).
|
|
16
|
+
installGroups: [core]
|
|
16
17
|
---
|
|
17
18
|
|
|
18
19
|
# Pikku Knowledge
|