@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.
@@ -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 `fabric persona` command.
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 `fabric init` writes
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 --sync --auto-approve
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; drop
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
- By default `apply` queues the deploy, prints the deployment id and returns. That
305
- tells you nothing about whether it worked. `--sync` waits for a terminal state
306
- and exits non-zero unless the deployment went live, which is the only form worth
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, without `--sync`) |
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
- `--auto-approve` does that; without it you get exit 3 and the command to run.
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 — `--auto-approve` alone declines and exits 3,
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. `--auto-approve` will **not** force this through;
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
- `--sync` defaults to a 900s ceiling; `--timeout <seconds>` moves it. On timeout
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 --auto-approve --json | jq -r 'select(.event=="result").deploymentId')
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" --sync --auto-approve
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 `--branch`/`--production`, which would let the two disagree.
369
+ combined with a branch or `--production`, which would let the two disagree.
353
370
 
354
- Under `--json`, `--sync` emits one NDJSON event per line — `created`/`attached`,
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`.** No re-export module, no branding layer. `m.some__key()`
67
- already satisfies the `I18nNode` gate; a wrapper adds nothing and costs
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