@bakers-build/addon-bakers-ui-artifacts 0.0.0-stage → 0.1.0-next.38

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keytec GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @bakers-build/addon-bakers-ui-artifacts
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ bakers.build TUI: an Artifacts tab on ticket detail, reading assets-ai/<bakers.build-nnn>/.
4
+
5
+ Part of the bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://git.key-tec.de/bakers-build/bakers-build (bakers-cli/).
@@ -0,0 +1,36 @@
1
+ import { type ActionItem, AsyncScreen, type RouteMetadata, type ScreenAction, type ScreenContext, type TicketDetailDocument } from '@bakers-build/addon-bakers-ui/ui';
2
+ export declare class ArtifactsTabScreen extends AsyncScreen<TicketDetailDocument> {
3
+ static readonly route: RouteMetadata;
4
+ readonly id = "ticket.artifacts";
5
+ readonly frameId = "ticket";
6
+ private selectedEntry;
7
+ private expanded;
8
+ private status;
9
+ private releaseStore;
10
+ protected load(context: ScreenContext): Promise<TicketDetailDocument>;
11
+ enter(context: ScreenContext): void;
12
+ leave(): void;
13
+ reload(context: ScreenContext): void;
14
+ protected renderLoading(): string[];
15
+ private sections;
16
+ private view;
17
+ protected renderReady(_document: TicketDetailDocument, context: ScreenContext): import("@opentui/core").VNode<any, import("@opentui/core").VChild[]>;
18
+ protected chrome(context: ScreenContext): {
19
+ selectable: number;
20
+ selectedIndex: number;
21
+ rowIds: string[];
22
+ cursorMode: "select" | "scroll";
23
+ header?: import("@opentui/core").VNode<any, import("@opentui/core").VChild[]>;
24
+ title: string;
25
+ };
26
+ protected actions(context: ScreenContext): readonly ActionItem[];
27
+ protected notice(): string | undefined;
28
+ select(index: number, context: ScreenContext): boolean;
29
+ activate(index: number, context: ScreenContext): boolean;
30
+ handleInput(input: string, context: ScreenContext): boolean;
31
+ commandForInput(input: string): "edit-assignment" | undefined;
32
+ actionForInput(input: string, context: ScreenContext): ScreenAction | undefined;
33
+ receiveResult(result: unknown, context: ScreenContext): void;
34
+ renderNonInteractive(context: ScreenContext): string[];
35
+ reset(): void;
36
+ }
@@ -0,0 +1,201 @@
1
+ import { AsyncScreen, actionItem, firstLineSummary, flattenSections, handleTicketCopy, plainLine, readTicket, saveTicket, tabViewTree, ticketCopyActions, ticketEditAction, ticketHeaderTree, ticketInputContext, ticketTitleOf, watchTicket, } from '@bakers-build/addon-bakers-ui/ui';
2
+ import { artifactRoots, artifactSections } from './tree.js';
3
+ /** How many lines of one artifact an expanded entry shows (spec D9). */
4
+ const HEAD_LINES = 200;
5
+ const attribute = (doc, name) => {
6
+ const value = doc?.data?.attributes?.[name];
7
+ return typeof value === 'string' ? value : '';
8
+ };
9
+ export class ArtifactsTabScreen extends AsyncScreen {
10
+ static route = {
11
+ id: 'ticket.artifacts',
12
+ path: '/tickets/:ticketId/artifacts',
13
+ parent: 'project.tickets',
14
+ tab: { id: 'artifacts', label: 'Artifacts', scope: 'ticket', weight: 50 },
15
+ fallback: { routeId: 'my.work' },
16
+ context: ticketInputContext,
17
+ };
18
+ id = 'ticket.artifacts';
19
+ frameId = 'ticket';
20
+ selectedEntry = 0;
21
+ expanded = new Set();
22
+ status = '';
23
+ releaseStore;
24
+ load(context) {
25
+ return Promise.resolve(readTicket(context));
26
+ }
27
+ enter(context) {
28
+ this.releaseStore?.();
29
+ super.enter(context);
30
+ if (this.state === 'ready')
31
+ this.replaceReady(readTicket(context, this.readyValue));
32
+ this.releaseStore = watchTicket(context, (document) => {
33
+ if (this.state === 'ready')
34
+ this.replaceReady(document);
35
+ context.requestDraw();
36
+ });
37
+ }
38
+ leave() {
39
+ this.releaseStore?.();
40
+ this.releaseStore = undefined;
41
+ super.leave();
42
+ }
43
+ reload(context) {
44
+ this.retry(context);
45
+ }
46
+ renderLoading() {
47
+ return ['Loading artifacts…'];
48
+ }
49
+ sections(context) {
50
+ const doc = this.readyValue;
51
+ const ctx = { cwd: context.options?.cwd };
52
+ const identifier = attribute(doc, 'identifier');
53
+ if (ctx.cwd === undefined || identifier === '') {
54
+ return [
55
+ {
56
+ heading: 'Artifacts',
57
+ lines: [
58
+ 'This cockpit has no checkout to read artifacts from.',
59
+ 'Artifacts are transient and local (BB-371 D1/D10).',
60
+ ],
61
+ },
62
+ ];
63
+ }
64
+ const roots = artifactRoots(ctx.cwd, identifier, attribute(doc, 'branch_name'));
65
+ const sections = artifactSections(roots, { headLines: HEAD_LINES });
66
+ // The empty state NAMES BOTH PROBED PATHS. BB-371 D10 is a real hole —
67
+ // on another machine the worktree is gone — and naming the paths is what
68
+ // turns a blank tab into a diagnosis.
69
+ return sections.length > 0
70
+ ? sections
71
+ : [
72
+ {
73
+ heading: 'Artifacts',
74
+ lines: [
75
+ 'No artifacts in this checkout. Probed:',
76
+ ...roots.map((root) => ` ${root}`),
77
+ '',
78
+ 'Artifacts die with the worktree (BB-371 D1/D10).',
79
+ ],
80
+ },
81
+ ];
82
+ }
83
+ view(context) {
84
+ return tabViewTree(this.readyValue, -1, {
85
+ panelSections: this.sections(context),
86
+ expanded: this.expanded,
87
+ selectedIndex: this.selectedEntry,
88
+ });
89
+ }
90
+ renderReady(_document, context) {
91
+ return this.view(context).body;
92
+ }
93
+ chrome(context) {
94
+ const document = this.readyValue;
95
+ const { identifier, title } = ticketTitleOf(document ?? {});
96
+ const view = this.view(context);
97
+ return {
98
+ title: document?.data
99
+ ? `${identifier} · ${title}`
100
+ : (context.params?.ticketId ?? 'ticket'),
101
+ ...(document?.data
102
+ ? { header: ticketHeaderTree(document, identifier, title, []) }
103
+ : {}),
104
+ selectable: view.selectable,
105
+ selectedIndex: this.selectedEntry,
106
+ rowIds: view.rowIds,
107
+ cursorMode: view.selectable > 1 ? 'select' : 'scroll',
108
+ };
109
+ }
110
+ actions(context) {
111
+ const count = this.view(context).selectable;
112
+ return [
113
+ actionItem('detail-tab', 'Tab', 'section'),
114
+ actionItem('detail-edit', 'e', 'edit'),
115
+ ...ticketCopyActions(),
116
+ actionItem('detail-arrows', '↑↓', count > 1 ? 'select' : 'scroll'),
117
+ ...(count > 0
118
+ ? [actionItem('detail-enter', 'Enter/Space', 'expand')]
119
+ : []),
120
+ ];
121
+ }
122
+ notice() {
123
+ return this.status || undefined;
124
+ }
125
+ select(index, context) {
126
+ const next = Math.max(0, Math.min(index, this.view(context).selectable - 1));
127
+ if (next === this.selectedEntry)
128
+ return false;
129
+ this.selectedEntry = next;
130
+ context.requestDraw();
131
+ return true;
132
+ }
133
+ activate(index, context) {
134
+ if (!this.view(context).selectable)
135
+ return false;
136
+ if (this.expanded.has(index))
137
+ this.expanded.delete(index);
138
+ else
139
+ this.expanded.add(index);
140
+ context.requestDraw();
141
+ return true;
142
+ }
143
+ handleInput(input, context) {
144
+ if (super.handleInput(input, context))
145
+ return true;
146
+ if (this.state !== 'ready')
147
+ return false;
148
+ if (input === 'y' || input === 'Y') {
149
+ this.status = handleTicketCopy(input, context, this.readyValue) ?? '';
150
+ context.requestDraw();
151
+ return true;
152
+ }
153
+ if (input === 'enter' || input === ' ')
154
+ return this.activate(this.selectedEntry, context);
155
+ const count = this.view(context).selectable;
156
+ if (count > 1) {
157
+ if (input === 'down' || input === 'j')
158
+ return this.select(this.selectedEntry + 1, context);
159
+ if (input === 'up' || input === 'k')
160
+ return this.select(this.selectedEntry - 1, context);
161
+ if (input === 'g')
162
+ return this.select(0, context);
163
+ if (input === 'G')
164
+ return this.select(count - 1, context);
165
+ }
166
+ return false;
167
+ }
168
+ commandForInput(input) {
169
+ return this.state === 'ready' && input === 'e'
170
+ ? 'edit-assignment'
171
+ : undefined;
172
+ }
173
+ actionForInput(input, context) {
174
+ return this.commandForInput(input)
175
+ ? ticketEditAction(context, this.readyValue)
176
+ : undefined;
177
+ }
178
+ receiveResult(result, context) {
179
+ const returned = result;
180
+ if (returned?.type === 'ticket-assignment-updated' && returned.document) {
181
+ this.replaceReady(saveTicket(context, returned.document));
182
+ context.requestDraw();
183
+ }
184
+ }
185
+ renderNonInteractive(context) {
186
+ return flattenSections(this.sections(context).map((section) => ({
187
+ ...section,
188
+ lines: section.expandable
189
+ ? firstLineSummary(section.lines.map((line) => plainLine(String(line)))).map((line) => line.text)
190
+ : [...section.lines],
191
+ }))).lines;
192
+ }
193
+ reset() {
194
+ this.releaseStore?.();
195
+ this.releaseStore = undefined;
196
+ super.reset();
197
+ this.selectedEntry = 0;
198
+ this.expanded = new Set();
199
+ this.status = '';
200
+ }
201
+ }
@@ -0,0 +1,3 @@
1
+ import type { BbPreset } from '@bakers-build/core';
2
+ export declare const uiEntries: BbPreset['uiEntries'];
3
+ export declare const scopes: BbPreset['scopes'];
@@ -0,0 +1,6 @@
1
+ export const uiEntries = (acc) => [
2
+ ...acc,
3
+ '@bakers-build/addon-bakers-ui-artifacts/ui',
4
+ ];
5
+ // Configurable on either level; the machine entry's with wins.
6
+ export const scopes = ['machine', 'project'];
@@ -0,0 +1,34 @@
1
+ import type { Section } from '@bakers-build/addon-bakers-ui/ui';
2
+ type ArtifactSection = Section & {
3
+ expandable?: boolean;
4
+ };
5
+ /**
6
+ * The roots to probe, in order (spec D9).
7
+ *
8
+ * Pure path joins: `cwd` comes from `TuiOptions`, `identifier` and `branch` off
9
+ * the open document, so there is no `git` call and nothing async. The second
10
+ * root is not a nicety — an operator starts `bakers ui` in the main checkout while
11
+ * a run writes its artifacts inside the worktree, so probing only the first
12
+ * would show nothing, systematically.
13
+ */
14
+ export declare function artifactRoots(cwd: string, identifier: string, branch: string): string[];
15
+ /**
16
+ * One section per ARTIFACT, headed by its path under the ticket's tree so the
17
+ * producing tool leads every heading (`vitest/store-green.txt`,
18
+ * `playwright/logs/walk.txt`) and one tool's artifacts stand together.
19
+ *
20
+ * Why per artifact and not per tool directory: the panel contract is
21
+ * `ArtifactSection[]` with one collapsible entry per section (spec D4), and D9 asks
22
+ * for each FILE to be the expandable entry — its collapsed row the producing
23
+ * command, its expanded body the head of the capture. A section per tool could
24
+ * carry only one of those two, so the grouping lives in the heading instead,
25
+ * where it costs the narrow contract nothing.
26
+ *
27
+ * The first row of every text artifact is its own first line, which by BB-371
28
+ * D3 is the command that produced it — so the collapsed panel reads as the list
29
+ * of commands this ticket ran.
30
+ */
31
+ export declare function artifactSections(roots: readonly string[], { headLines }: {
32
+ headLines: number;
33
+ }): readonly ArtifactSection[];
34
+ export {};
@@ -0,0 +1,141 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * The roots to probe, in order (spec D9).
5
+ *
6
+ * Pure path joins: `cwd` comes from `TuiOptions`, `identifier` and `branch` off
7
+ * the open document, so there is no `git` call and nothing async. The second
8
+ * root is not a nicety — an operator starts `bakers ui` in the main checkout while
9
+ * a run writes its artifacts inside the worktree, so probing only the first
10
+ * would show nothing, systematically.
11
+ */
12
+ export function artifactRoots(cwd, identifier, branch) {
13
+ const own = join(cwd, 'assets-ai', identifier);
14
+ return branch.trim() === ''
15
+ ? [own]
16
+ : [own, join(cwd, '.bakers-worktrees', branch, 'assets-ai', identifier)];
17
+ }
18
+ /**
19
+ * Text file heads, memoised per `(path, mtime, size)` — spec D9.
20
+ *
21
+ * That key is what makes the cache CORRECT rather than merely fast: keying on
22
+ * the directory's own mtime would go stale the moment a re-run overwrites a log
23
+ * in place, because rewriting a file does not touch its directory. There is no
24
+ * reliance on the `R` key either — it reloads the document and knows nothing
25
+ * about this tree.
26
+ */
27
+ const heads = new Map();
28
+ const head = (path, lines) => {
29
+ const stat = statSync(path);
30
+ const key = `${path}:${stat.mtimeMs}:${stat.size}`;
31
+ const hit = heads.get(key);
32
+ if (hit)
33
+ return hit;
34
+ const all = readFileSync(path, 'utf8').replace(/\n$/, '').split('\n');
35
+ const kept = all.slice(0, lines);
36
+ if (all.length > lines)
37
+ kept.push(`… ${all.length - lines} more lines`);
38
+ heads.set(key, kept);
39
+ return kept;
40
+ };
41
+ /** Extensions never read: their bytes say nothing a terminal can show. */
42
+ const BINARY = /\.(png|jpe?g|gif|webp|zip|pdf|webm|mp4)$/i;
43
+ /**
44
+ * One directory's entries, or none when it cannot be read.
45
+ *
46
+ * A directory that vanished between the walk and the read is not an error worth
47
+ * a stack trace — this runs on the DRAW path, and a run writing into the tree is
48
+ * exactly the situation the panel exists for.
49
+ */
50
+ const entriesOf = (dir) => {
51
+ try {
52
+ return readdirSync(dir, { withFileTypes: true });
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ };
58
+ /**
59
+ * One FILE's section — and the same reasoning `entriesOf` states, applied to the
60
+ * calls that actually touch bytes.
61
+ *
62
+ * The race that comment names is not hypothetical for a file: a run unlinks or
63
+ * rewrites a capture between `readdirSync` and this read and the `stat`/`open`
64
+ * comes back ENOENT; a log written by a container as root comes back EACCES. On
65
+ * the DRAW path either one used to escape as a stack trace, and the host's guard
66
+ * would then have degraded the WHOLE listing to a single failure row — so one
67
+ * unreadable file would hide every artifact beside it. It costs its own row
68
+ * instead: the file stays listed under its real heading, the row says plainly
69
+ * that it could not be read, and it is NOT expandable, because there is nothing
70
+ * behind the caret to show. The errno leads, since `ENOENT`/`EACCES` is the part
71
+ * that tells the operator whether to re-run or to fix permissions.
72
+ */
73
+ const fileSection = (heading, path, headLines) => {
74
+ try {
75
+ return BINARY.test(heading)
76
+ ? // Listed, never read — and NOT expandable, because there is nothing
77
+ // behind the caret a terminal could show.
78
+ { heading, lines: [`${statSync(path).size} bytes`] }
79
+ : { heading, expandable: true, lines: [...head(path, headLines)] };
80
+ }
81
+ catch (caught) {
82
+ const code = caught?.code;
83
+ return {
84
+ heading,
85
+ lines: [
86
+ `could not be read: ${typeof code === 'string'
87
+ ? code
88
+ : caught instanceof Error
89
+ ? caught.message
90
+ : String(caught)}`,
91
+ ],
92
+ };
93
+ }
94
+ };
95
+ /**
96
+ * One section per ARTIFACT, headed by its path under the ticket's tree so the
97
+ * producing tool leads every heading (`vitest/store-green.txt`,
98
+ * `playwright/logs/walk.txt`) and one tool's artifacts stand together.
99
+ *
100
+ * Why per artifact and not per tool directory: the panel contract is
101
+ * `ArtifactSection[]` with one collapsible entry per section (spec D4), and D9 asks
102
+ * for each FILE to be the expandable entry — its collapsed row the producing
103
+ * command, its expanded body the head of the capture. A section per tool could
104
+ * carry only one of those two, so the grouping lives in the heading instead,
105
+ * where it costs the narrow contract nothing.
106
+ *
107
+ * The first row of every text artifact is its own first line, which by BB-371
108
+ * D3 is the command that produced it — so the collapsed panel reads as the list
109
+ * of commands this ticket ran.
110
+ */
111
+ export function artifactSections(roots, { headLines }) {
112
+ const root = roots.find((candidate) => {
113
+ try {
114
+ return statSync(candidate).isDirectory();
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ });
120
+ if (root === undefined)
121
+ return [];
122
+ const sections = [];
123
+ const walk = (dir, prefix) => {
124
+ // Sorted by name, so the listing is the same on every draw and on every
125
+ // filesystem — `readdirSync` promises no order of its own.
126
+ const sorted = [...entriesOf(dir)].sort((a, b) => a.name.localeCompare(b.name));
127
+ for (const entry of sorted) {
128
+ const path = join(dir, entry.name);
129
+ const heading = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
130
+ if (entry.isDirectory()) {
131
+ walk(path, heading);
132
+ continue;
133
+ }
134
+ if (!entry.isFile())
135
+ continue;
136
+ sections.push(fileSection(heading, path, headLines));
137
+ }
138
+ };
139
+ walk(root, '');
140
+ return sections;
141
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/src/ui.js ADDED
@@ -0,0 +1,6 @@
1
+ // The entry module: it registers at IMPORT time, which is Storybook-faithful for
2
+ // the addon author and is why the kernel drains the collector once rather than
3
+ // handing a store around (spec D3).
4
+ import { register } from '@bakers-build/addon-bakers-ui/ui';
5
+ import { ArtifactsTabScreen } from './panel.js';
6
+ register('bakers/artifacts', (api) => api.add(ArtifactsTabScreen));
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-bakers-ui-artifacts",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-next.38",
4
+ "description": "bakers.build TUI: an Artifacts tab on ticket detail, reading assets-ai/<bakers.build-nnn>/.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "exports": {
8
+ ".": "./dist/src/panel.js",
9
+ "./preset": "./dist/src/preset.js",
10
+ "./ui": "./dist/src/ui.js"
11
+ },
12
+ "files": [
13
+ "dist/src"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://git.key-tec.de/bakers-build/bakers-build.git",
21
+ "directory": "bakers-cli/addons/bakers-ui-artifacts"
22
+ },
23
+ "peerDependencies": {
24
+ "@bakers-build/addon-bakers-ui": "^0.1.0-next.38",
25
+ "@bakers-build/core": "^0.1.0-next.38"
26
+ }
27
+ }