@orbytes/astrolab 0.3.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/LICENSE +37 -0
- package/README.md +410 -0
- package/bin/lab-cull.mjs +401 -0
- package/bin/pin-gallery.mjs +121 -0
- package/defaults.mjs +120 -0
- package/dist/core/astro-integration.js +130 -0
- package/dist/core/index.js +6 -0
- package/dist/core/lib-paths.js +29 -0
- package/dist/core/options.js +173 -0
- package/dist/core/utils/get-exports.js +52 -0
- package/dist/core/utils/invariant.js +12 -0
- package/dist/core/utils/kebab-case.js +15 -0
- package/dist/core/utils/path-builder.js +22 -0
- package/dist/core/utils/path.js +53 -0
- package/dist/core/virtual-module/get-story-modules.js +60 -0
- package/dist/core/virtual-module/story-modules.js +8 -0
- package/dist/core/virtual-module/virtual-module-ids.js +22 -0
- package/dist/core/virtual-module/virtual-routes.js +83 -0
- package/dist/core/virtual-module/vite-plugin.js +98 -0
- package/docs/PIN-CONTRACT.md +263 -0
- package/docs/PIN.md +429 -0
- package/index.d.ts +279 -0
- package/index.mjs +347 -0
- package/package.json +93 -0
- package/src/Empty.astro +4 -0
- package/src/Home.astro +298 -0
- package/src/LabHead.astro +1102 -0
- package/src/core/LICENSE-astrobook +166 -0
- package/src/core/astro-integration.ts +166 -0
- package/src/core/client.ts +89 -0
- package/src/core/index.ts +7 -0
- package/src/core/lib/components/empty.astro +1 -0
- package/src/core/lib/components/head.astro +1 -0
- package/src/core/lib/components/home.astro +8 -0
- package/src/core/lib/components/with-decorators.astro +22 -0
- package/src/core/lib/pages/app.astro +19 -0
- package/src/core/lib/pages/preview.astro +17 -0
- package/src/core/lib/pages/story.astro +16 -0
- package/src/core/lib-paths.ts +72 -0
- package/src/core/options.ts +262 -0
- package/src/core/utils/get-exports.ts +59 -0
- package/src/core/utils/invariant.ts +13 -0
- package/src/core/utils/kebab-case.ts +30 -0
- package/src/core/utils/path-builder.ts +45 -0
- package/src/core/utils/path.ts +80 -0
- package/src/core/virtual-module/get-story-modules.ts +110 -0
- package/src/core/virtual-module/story-modules.ts +9 -0
- package/src/core/virtual-module/virtual-module-ids.ts +17 -0
- package/src/core/virtual-module/virtual-routes.ts +130 -0
- package/src/core/virtual-module/vite-plugin.ts +125 -0
- package/src/pin/board.mjs +1521 -0
- package/src/pin/index.mjs +666 -0
- package/src/pin/shot.mjs +427 -0
- package/src/pin/source-stamp.mjs +159 -0
- package/src/pin/tickets.mjs +697 -0
- package/src/pin/toolbar.js +3181 -0
- package/src/shell/Browse.astro +371 -0
- package/src/shell/CardGrid.astro +297 -0
- package/src/shell/Viewport.astro +1330 -0
- package/src/shell/index.json.ts +12 -0
- package/src/shell/lab-index.ts +344 -0
- package/src/shell/lab-params.ts +245 -0
- package/src/shell/live-files.mjs +164 -0
- package/src/shell/marks.mjs +136 -0
- package/src/types/index.ts +6 -0
- package/src/types/types.ts +239 -0
- package/src/types/virtual.d.ts +29 -0
- package/src/ui/components/app.astro +13 -0
- package/src/ui/components/build-path.ts +13 -0
- package/src/ui/components/build-tree.ts +108 -0
- package/src/ui/components/collapse-duration.ts +28 -0
- package/src/ui/components/compress-terms.ts +10 -0
- package/src/ui/components/dashboard-layout.astro +39 -0
- package/src/ui/components/home.astro +65 -0
- package/src/ui/components/layout.astro +110 -0
- package/src/ui/components/preview-layout.astro +109 -0
- package/src/ui/components/sidebar-button-fullscreen.astro +38 -0
- package/src/ui/components/sidebar-button-search.astro +23 -0
- package/src/ui/components/sidebar-button-theme.astro +9 -0
- package/src/ui/components/sidebar-button.astro +24 -0
- package/src/ui/components/sidebar-resize-handle.astro +74 -0
- package/src/ui/components/sidebar-search-panel.astro +41 -0
- package/src/ui/components/sidebar-search-script.ts +103 -0
- package/src/ui/components/sidebar-title.astro +17 -0
- package/src/ui/components/sidebar-tree-node.astro +143 -0
- package/src/ui/components/sidebar-tree.astro +84 -0
- package/src/ui/components/sidebar.astro +29 -0
- package/src/ui/components/theme-message.ts +26 -0
- package/src/ui/components/theme-script.astro +71 -0
- package/src/ui/components/theme-toggle.astro +63 -0
- package/src/ui/components/theme.ts +32 -0
- package/src/ui/index.ts +4 -0
- package/src/ui/lab.css +549 -0
- package/virtual.d.ts +42 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
// orbytes-pin — click a rendered element on `astro dev`, leave a comment, get a markdown ticket
|
|
2
|
+
// and a PNG on local disk for an agent to pick up.
|
|
3
|
+
//
|
|
4
|
+
// SHIPPED AS HALF OF THE LAB since 2026-09-22 — decided that day: the lab and the pin board are ONE
|
|
5
|
+
// app, bundled and working as one, not two packages a site installs separately. A consumer configures
|
|
6
|
+
// ONE integration and gets both:
|
|
7
|
+
//
|
|
8
|
+
// import orbytesLab from "@orbytes/astrolab";
|
|
9
|
+
// integrations: [ includeLab ? orbytesLab({ css: [...] }) : null ] // /lab and /pin
|
|
10
|
+
// integrations: [ includeLab ? orbytesLab({ pin: false }) : null ] // /lab alone
|
|
11
|
+
//
|
|
12
|
+
// `orbytesLab()` returns an array Astro flattens, and this integration is one of its entries — see
|
|
13
|
+
// ../../index.mjs, which also hands it the lab's RESOLVED subpath so the board's "Lab" link points
|
|
14
|
+
// at wherever the lab actually is. Calling this module directly still works and is what the tests
|
|
15
|
+
// do; nothing about it assumes the lab is present.
|
|
16
|
+
//
|
|
17
|
+
// No cloud, no API key, no MCP, no second process — ../../docs/PIN-CONTRACT.md. It replaces the
|
|
18
|
+
// hosted-widget → issue-tracker feedback chain for a solo build pass, and only for that.
|
|
19
|
+
//
|
|
20
|
+
// Three jobs:
|
|
21
|
+
// 1. stamp `data-orbytes-src="<repo-relative path>"` on the first top-level element of every
|
|
22
|
+
// `.astro` file under the configured directories, so the picker can name the file an element
|
|
23
|
+
// came from (./source-stamp.mjs — Astro 7 emits no source attribute of its own).
|
|
24
|
+
// 2. register the dev toolbar app the picking happens in (./toolbar.js — the client half: the
|
|
25
|
+
// element picker, the comment panel and the ticket list, loaded by Astro's dev toolbar).
|
|
26
|
+
// 3. on the toolbar's `orbytes-pin:create`, write the ticket and REPLY, then take the
|
|
27
|
+
// screenshot and send a second event when it lands (./tickets.mjs, ./shot.mjs).
|
|
28
|
+
// 4. serve the KANBAN at `<route>` — every ticket in `backlog/tasks`, read fresh on each
|
|
29
|
+
// request — its screenshots at `<route>/assets/*`, and the one write endpoint at
|
|
30
|
+
// `<route>/api/ticket`, all from the dev server you are already on (./board.mjs, which
|
|
31
|
+
// `orbytes-pin-gallery` renders the standalone file with too).
|
|
32
|
+
//
|
|
33
|
+
// `route` DEFAULTS TO `/pin` AND SHADOWS ANY HOST PAGE AT THAT PATH. This middleware is installed
|
|
34
|
+
// before Astro's own request handler, so a site with its own `src/pages/pin.astro` would serve the
|
|
35
|
+
// board instead of its page, in dev only, with nothing on screen to say why. The default stays —
|
|
36
|
+
// `/pin` is the chosen address and the one every doc names — and the collision is a
|
|
37
|
+
// one-liner to avoid: `pin: { route: "/__pin" }`. Said here, in ../../docs/PIN.md and in
|
|
38
|
+
// ../../README.md, because a silent shadow is only findable if somebody wrote it down.
|
|
39
|
+
//
|
|
40
|
+
// The board is MIDDLEWARE, not an injected route, and the choice is deliberate. The lab injects its
|
|
41
|
+
// pages because they are prerendered; this one is generated per request from files outside the
|
|
42
|
+
// site, so it belongs with the lab's other half — the mark APIs it serves from `astro:server:setup`.
|
|
43
|
+
// That hook does not exist in a build, so `/pin` cannot leak into `dist/` even by accident, and
|
|
44
|
+
// nothing has to be marked `prerender: false` in a project with `output: "static"` and no adapter.
|
|
45
|
+
// ── backlog.md is retired (2026-09-22) ──────────────────────────────────────────────────────────
|
|
46
|
+
// Until today `<route>` was read-only and pointed at backlog.md's own web UI — a second server on
|
|
47
|
+
// a third port — for the one thing it could not do: change a ticket's status. That made three
|
|
48
|
+
// places to remember. backlog.md re-serialises frontmatter on any write and silently drops every
|
|
49
|
+
// key it does not know, which is why the pin fields live in a fenced block in the BODY, and a
|
|
50
|
+
// SECOND writer of that frontmatter would have rebuilt the bug.
|
|
51
|
+
//
|
|
52
|
+
// With backlog.md out of the loop there is no second writer, so `<route>/api/ticket` can write
|
|
53
|
+
// those files correctly by construction: `updateTicket` (./board.mjs) rewrites only the
|
|
54
|
+
// frontmatter lines it is changing and returns the body — comment, image and fenced pin block —
|
|
55
|
+
// byte for byte, asserting that on every write rather than documenting it. That endpoint is the
|
|
56
|
+
// ONLY write path, it is POST-only, same-origin, and it lives in `astro:server:setup`, which does
|
|
57
|
+
// not exist in a build. Do not run `backlog browser`; do not run `backlog task edit` on a pin
|
|
58
|
+
// ticket.
|
|
59
|
+
//
|
|
60
|
+
// DEV ONLY, asserted FOUR times independently, because the whole write path's safety rests on it
|
|
61
|
+
// (contract non-negotiable 1). All four are kept, and none of them is the spare:
|
|
62
|
+
// · `astro:config:setup` returns early unless `command === "dev"` — nothing is registered;
|
|
63
|
+
// · the Vite plugins carry `apply: "serve"`, so they cannot load in a build even if they were;
|
|
64
|
+
// · `astro:server:setup` never runs in a build at all, which is where the writes live;
|
|
65
|
+
// · that hook still returns early when `repoRoot` is null, i.e. when config:setup stood down —
|
|
66
|
+
// the one that covers a host that calls the hooks in an order this file did not choose.
|
|
67
|
+
//
|
|
68
|
+
// This matters more now than it did in its own package. The lab is deliberately included in
|
|
69
|
+
// STAGING builds (`PUBLIC_DEPLOY_ENV=staging`), and `orbytesLab()` returns this integration
|
|
70
|
+
// alongside it — so from 2026-09-22 this file is registered in a build for the first time. The
|
|
71
|
+
// first assertion is what makes that a no-op: `command` is `build` there, not `dev`.
|
|
72
|
+
//
|
|
73
|
+
// Ordering is the one thing not to tune. The reply goes out on the same synchronous tick as the
|
|
74
|
+
// ticket write; the browser launch happens after it. A ticket with no screenshot yet is valid, a
|
|
75
|
+
// ticket somebody waited two seconds for is not.
|
|
76
|
+
import { fileURLToPath } from "node:url";
|
|
77
|
+
import { homedir } from "node:os";
|
|
78
|
+
import { createReadStream, realpathSync, statSync } from "node:fs";
|
|
79
|
+
import { extname, join, resolve, sep } from "node:path";
|
|
80
|
+
import { findRepoRoot, projectName, writeTicket } from "./tickets.mjs";
|
|
81
|
+
import { sourceStampPlugin } from "./source-stamp.mjs";
|
|
82
|
+
import { close as closeBrowser, shoot } from "./shot.mjs";
|
|
83
|
+
import { collectTickets, renderBoard, renderCard, updateTicket } from "./board.mjs";
|
|
84
|
+
|
|
85
|
+
const file = (relative) => fileURLToPath(new URL(relative, import.meta.url));
|
|
86
|
+
|
|
87
|
+
/** One plain sentence, never a stack — this string is shown to the user in the toolbar. */
|
|
88
|
+
const oneLine = (e) => String(e?.message ?? e).split("\n")[0].trim();
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What `<route>/assets/*` will serve. An allowlist, not a lookup with a fallback: an extension
|
|
92
|
+
* that is not on this list is a 404, so no file type can be handed out because nobody thought
|
|
93
|
+
* about it. Every PNG the picker writes is the first entry; the rest are for a shot dropped in
|
|
94
|
+
* by hand.
|
|
95
|
+
*/
|
|
96
|
+
const ASSET_MIME = {
|
|
97
|
+
".png": "image/png",
|
|
98
|
+
".jpg": "image/jpeg",
|
|
99
|
+
".jpeg": "image/jpeg",
|
|
100
|
+
".webp": "image/webp",
|
|
101
|
+
".gif": "image/gif",
|
|
102
|
+
".avif": "image/avif",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `child` is inside `parent`, or is `parent`. Both must already be absolute and resolved.
|
|
107
|
+
* The `+ sep` is the whole point: a plain `startsWith` would let `/x/backlog-secrets` pass as
|
|
108
|
+
* inside `/x/backlog`.
|
|
109
|
+
*/
|
|
110
|
+
const contains = (parent, child) => child === parent || child.startsWith(parent + sep);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The one file `<route>/assets/<rel>` names, or null when it names anything else.
|
|
114
|
+
*
|
|
115
|
+
* The screenshots sit in `backlog/assets/`, outside `site/public/`, so the dev server has no route
|
|
116
|
+
* to them and this opens exactly one — a single directory, image types only, read-only. Four
|
|
117
|
+
* refusals, in order, because each catches something the one before it cannot:
|
|
118
|
+
*
|
|
119
|
+
* 1. a null byte, which truncates a path inside some syscalls;
|
|
120
|
+
* 2. containment AFTER `resolve`, which is what stops `../` and an absolute `/etc/passwd`
|
|
121
|
+
* (`new URL()` normalises literal dot segments in a pathname, so the form that actually
|
|
122
|
+
* arrives here is the percent-encoded one — `%2e%2e%2f` — and it is decoded above this
|
|
123
|
+
* line precisely so this check is the thing that sees it);
|
|
124
|
+
* 3. containment AGAIN after `realpathSync`, because step 2 is defeated by a symlink inside
|
|
125
|
+
* the directory pointing out of it, and the PNGs here are hardlinks into ~/.orbytes, so
|
|
126
|
+
* links in this folder are normal rather than suspicious;
|
|
127
|
+
* 4. a regular file with a known image extension — a directory, a socket or a `.md` is a 404.
|
|
128
|
+
*/
|
|
129
|
+
function resolveAsset(assetsRoot, rel) {
|
|
130
|
+
if (!rel || rel.includes("\0")) return null;
|
|
131
|
+
const abs = resolve(assetsRoot, rel);
|
|
132
|
+
if (!contains(assetsRoot, abs)) return null;
|
|
133
|
+
let real;
|
|
134
|
+
let realRoot;
|
|
135
|
+
try {
|
|
136
|
+
real = realpathSync(abs);
|
|
137
|
+
realRoot = realpathSync(assetsRoot);
|
|
138
|
+
} catch {
|
|
139
|
+
return null; // missing, or a broken link
|
|
140
|
+
}
|
|
141
|
+
if (!contains(realRoot, real)) return null;
|
|
142
|
+
const type = ASSET_MIME[extname(real).toLowerCase()];
|
|
143
|
+
if (!type) return null;
|
|
144
|
+
let stat;
|
|
145
|
+
try {
|
|
146
|
+
stat = statSync(real);
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
if (!stat.isFile()) return null;
|
|
151
|
+
return { file: real, type, size: stat.size };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The resolved options the TOOLBAR APP needs, handed to it as a virtual module.
|
|
156
|
+
*
|
|
157
|
+
* A dev-toolbar app entrypoint is instantiated by Astro with `(canvas, app, server)` and nothing
|
|
158
|
+
* else — there is no channel for integration options, which is why `./toolbar.js` carried a
|
|
159
|
+
* hardcoded `/pin` and a site setting `pin: { route: "/__pin" }` got a panel that fetched a route
|
|
160
|
+
* it does not serve. The lab solves the same problem the same way, with
|
|
161
|
+
* `virtual:orbytes-lab/config.mjs` (../../index.mjs), so this mirrors it rather than inventing a
|
|
162
|
+
* second mechanism.
|
|
163
|
+
*
|
|
164
|
+
* Client-side and dev-only: the plugin carries `apply: "serve"`, and in a build this whole
|
|
165
|
+
* integration stands down before `updateConfig` is ever reached.
|
|
166
|
+
*
|
|
167
|
+
* The resolved id carries no leading NUL, the same choice the lab and Astrobook make — Astro's
|
|
168
|
+
* pipeline handles a plain id and a NUL-prefixed one has to be special-cased.
|
|
169
|
+
*/
|
|
170
|
+
const PIN_VIRTUAL = "virtual:orbytes-pin/config.mjs";
|
|
171
|
+
const PIN_VIRTUAL_RESOLVED = "__virtual_orbytes_pin_config__.mjs";
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The board's path, with any trailing slashes off. ONE definition: the middleware matches on it
|
|
175
|
+
* and the toolbar app fetches and opens it, and the two disagreeing is the defect this replaces.
|
|
176
|
+
*/
|
|
177
|
+
const boardRoute = (raw) => String(raw ?? "").replace(/\/+$/, "") || "/pin";
|
|
178
|
+
|
|
179
|
+
const pinConfigPlugin = (exposed) => ({
|
|
180
|
+
name: "orbytes-pin/virtual",
|
|
181
|
+
apply: "serve",
|
|
182
|
+
resolveId: (id) => (id === PIN_VIRTUAL ? PIN_VIRTUAL_RESOLVED : undefined),
|
|
183
|
+
load: (id) =>
|
|
184
|
+
id === PIN_VIRTUAL_RESOLVED ? `export default ${JSON.stringify(exposed)};` : undefined,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const DEFAULTS = {
|
|
188
|
+
/** Site-relative directories whose `.astro` files get the source stamp. */
|
|
189
|
+
stamp: ["src/lab/sections", "src/components"],
|
|
190
|
+
/**
|
|
191
|
+
* Repo-relative `.astro` paths exempt from the stamp's hard failure. A file whose template has
|
|
192
|
+
* no element to stamp throws by design; this is the only way past it, and it is a deliberate,
|
|
193
|
+
* named decision per file rather than a warning that lets every such file through silently.
|
|
194
|
+
*/
|
|
195
|
+
stampSkip: [],
|
|
196
|
+
/** Repo-relative board directory. Tickets land in `<backlogDir>/tasks`, PNGs in `/assets`. */
|
|
197
|
+
backlogDir: "backlog",
|
|
198
|
+
/** Canonical screenshot home. The repo copy is a hardlink to a file in here. */
|
|
199
|
+
archiveDir: join(homedir(), ".orbytes", "feedback-archive"),
|
|
200
|
+
/**
|
|
201
|
+
* The archive folder this project's screenshots live in: `<archiveDir>/<project>/`.
|
|
202
|
+
*
|
|
203
|
+
* `null` derives it from the checkout's own directory name, which is what it always did and is
|
|
204
|
+
* right almost always — and wrong in the one case nobody would notice. TWO CHECKOUTS OF
|
|
205
|
+
* DIFFERENT REPOS UNDER THE SAME FOLDER NAME SHARE AN ARCHIVE, silently: `pin-007.png` from one
|
|
206
|
+
* overwrites `pin-007.png` from the other, both boards go on rendering (the repo copy is a
|
|
207
|
+
* hardlink, so the survivor keeps a name in each), and the only evidence is a screenshot of
|
|
208
|
+
* somebody else's site. Two clients called `site`, a `~/work/acme` beside a `~/archive/acme`,
|
|
209
|
+
* or a worktree named after its branch all reach it. Set it explicitly to be sure.
|
|
210
|
+
*
|
|
211
|
+
* Added on the move into astrolab, 2026-09-22 — a package that travels between repos is exactly
|
|
212
|
+
* where a path derived from the checkout stops being safe.
|
|
213
|
+
*/
|
|
214
|
+
project: null,
|
|
215
|
+
/** Set false to write tickets and take no screenshots at all. */
|
|
216
|
+
shots: true,
|
|
217
|
+
/**
|
|
218
|
+
* Where the board is served, dev only. `<route>/assets/*` serves its screenshots and
|
|
219
|
+
* `<route>/api/ticket` is the one write endpoint.
|
|
220
|
+
*
|
|
221
|
+
* It is MIDDLEWARE and it shadows a host page at the same path (see the note at the top of this
|
|
222
|
+
* file). A site with its own `/pin` page sets something else here.
|
|
223
|
+
*/
|
|
224
|
+
route: "/pin",
|
|
225
|
+
/**
|
|
226
|
+
* The links in the board's top-right corner, `[{ href, label }]`. `null` means the pair the
|
|
227
|
+
* board has always drawn — the site root and `/lab` — which was hardcoded until 2026-09-22 and
|
|
228
|
+
* is a guess anywhere the lab is not at `/lab`. `orbytesLab()` passes the lab's RESOLVED subpath
|
|
229
|
+
* rather than the guess; `[]` draws no links at all.
|
|
230
|
+
*/
|
|
231
|
+
links: null,
|
|
232
|
+
appId: "orbytes-pin",
|
|
233
|
+
appName: "Pin",
|
|
234
|
+
icon: "bug",
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/** The dev server's own origin, read from Vite. Never hardcode 4321 — Astro picks a free port. */
|
|
238
|
+
function devOrigin(server) {
|
|
239
|
+
const resolved = server.resolvedUrls?.local?.[0];
|
|
240
|
+
if (resolved) return resolved.replace(/\/+$/, "");
|
|
241
|
+
const address = server.httpServer?.address?.();
|
|
242
|
+
if (address && typeof address === "object") return `http://localhost:${address.port}`;
|
|
243
|
+
const port = server.config?.server?.port;
|
|
244
|
+
if (port) return `http://localhost:${port}`;
|
|
245
|
+
throw new Error("the dev server has not reported a port yet, so there is nowhere to screenshot from");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* @param {Partial<typeof DEFAULTS>} [options]
|
|
250
|
+
* @returns {import("astro").AstroIntegration}
|
|
251
|
+
*/
|
|
252
|
+
export default function orbytesPin(options = {}) {
|
|
253
|
+
const config = { ...DEFAULTS, ...options };
|
|
254
|
+
let repoRoot = null;
|
|
255
|
+
let project = null;
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
name: "orbytes-pin",
|
|
259
|
+
hooks: {
|
|
260
|
+
"astro:config:setup": ({ config: astroConfig, command, updateConfig, addDevToolbarApp, logger }) => {
|
|
261
|
+
// Assertion one. In `build`, `preview` or `sync` this integration registers nothing at
|
|
262
|
+
// all — no plugin, no toolbar app, no stamp.
|
|
263
|
+
if (command !== "dev") {
|
|
264
|
+
logger.debug(`command is ${command}, not dev — orbytes-pin stands down`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const siteRoot = fileURLToPath(astroConfig.root);
|
|
268
|
+
|
|
269
|
+
// Assertion one and a half. `findRepoRoot` throws rather than guess, which is right for a
|
|
270
|
+
// library — every ticket path hangs off it and a guessed root writes files somewhere
|
|
271
|
+
// nobody asked for. But an integration that lets that reach Astro takes the whole dev
|
|
272
|
+
// server down with it, and the board is ON BY DEFAULT: measured 2026-09-22, a freshly
|
|
273
|
+
// scaffolded Astro project (which has no git yet) could not run `astro dev` at all after
|
|
274
|
+
// installing this package. The lab half had already logged that it found its stories, so
|
|
275
|
+
// the failure looked like the package rather than the one check it was.
|
|
276
|
+
//
|
|
277
|
+
// So: stand down the way the non-dev path does, and say both escapes. The lab is
|
|
278
|
+
// unaffected, and `astro build` never reaches here at all.
|
|
279
|
+
try {
|
|
280
|
+
repoRoot = findRepoRoot(siteRoot);
|
|
281
|
+
} catch {
|
|
282
|
+
logger.warn(
|
|
283
|
+
`no git repository found above ${siteRoot} — the pin board needs one, because every ` +
|
|
284
|
+
`ticket path is stored relative to it. The board is disabled for this run; the lab ` +
|
|
285
|
+
`is unaffected. Run \`git init\` to enable it, or pass \`pin: false\` to silence this.`,
|
|
286
|
+
);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
project = String(config.project ?? projectName(repoRoot));
|
|
290
|
+
|
|
291
|
+
addDevToolbarApp({
|
|
292
|
+
id: config.appId,
|
|
293
|
+
name: config.appName,
|
|
294
|
+
icon: config.icon,
|
|
295
|
+
entrypoint: file("./toolbar.js"),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
updateConfig({
|
|
299
|
+
vite: {
|
|
300
|
+
plugins: [
|
|
301
|
+
sourceStampPlugin({
|
|
302
|
+
repoRoot,
|
|
303
|
+
siteRoot,
|
|
304
|
+
dirs: config.stamp,
|
|
305
|
+
skip: config.stampSkip,
|
|
306
|
+
}),
|
|
307
|
+
// The resolved options ./toolbar.js reads — today just the board's route, which it
|
|
308
|
+
// fetches for the ticket list and opens with the "Board ↗" button. Registered here
|
|
309
|
+
// rather than hardcoded there, because a toolbar app entrypoint is handed no
|
|
310
|
+
// options (› pinConfigPlugin, above).
|
|
311
|
+
pinConfigPlugin({ route: boardRoute(config.route) }),
|
|
312
|
+
// The browser outlives every request, so something has to end it. Vite fires
|
|
313
|
+
// `closeBundle` when the dev environment's plugin container closes; the httpServer
|
|
314
|
+
// handler in astro:server:setup covers the path where it does not, and Playwright
|
|
315
|
+
// kills its own child on process exit. Three, because a stray headless chromium is
|
|
316
|
+
// invisible until you notice the fans.
|
|
317
|
+
{ name: "orbytes-pin/lifecycle", apply: "serve", closeBundle: () => closeBrowser() },
|
|
318
|
+
],
|
|
319
|
+
// The same exemption the lab integration declares for itself. It is SSR-only by
|
|
320
|
+
// definition and cannot reach the CLIENT graph, which is where ./src/toolbar.js is
|
|
321
|
+
// loaded — checked 2026-09-21, because the toolbar's bare `astro/...` imports look
|
|
322
|
+
// like they might need one. They do not: resolving its three specifiers from this
|
|
323
|
+
// package's own directory, `astro/toolbar`,
|
|
324
|
+
// `astro/client/dev-toolbar/apps/utils/highlight.js` and `.../window.js` all resolve
|
|
325
|
+
// through astro's declared "./toolbar" and "./client/*" exports. NO alias for
|
|
326
|
+
// `astro` is declared anywhere here, and none may be added.
|
|
327
|
+
//
|
|
328
|
+
// The NAME is the lab's since the merge — one package now ships both halves, and this
|
|
329
|
+
// file is `@orbytes/astrolab/src/pin/index.mjs`. `orbytes-pin` no longer resolves to
|
|
330
|
+
// anything, so exempting it would exempt nothing. (The lab's own integration declares
|
|
331
|
+
// the same line; declaring it twice is harmless — Vite dedupes the array — and it is
|
|
332
|
+
// kept here so calling this module on its own still works.)
|
|
333
|
+
ssr: { noExternal: ["@orbytes/astrolab"] },
|
|
334
|
+
// The CLIENT-side counterpart, and the one that actually keeps the dev toolbar
|
|
335
|
+
// alive. Measured on astro 7.3.1 / vite 8.2.2, 2026-09-21.
|
|
336
|
+
//
|
|
337
|
+
// `node_modules/orbytes-pin` is a symlink to `packages/orbytes-pin`, and Vite
|
|
338
|
+
// resolves symlinks (`resolve.preserveSymlinks` is false by default). So the
|
|
339
|
+
// importer of ./src/toolbar.js is its REAL path, which is not inside
|
|
340
|
+
// node_modules — and Vite's `finalizeBareSpecifier` only skips the dep optimizer
|
|
341
|
+
// when `importer && isInNodeModules(importer)` (vite/dist/node/chunks/node.js,
|
|
342
|
+
// the `skipOptimization` expression). A package that genuinely sat in
|
|
343
|
+
// node_modules would be skipped; this one is workspace-linked, so it is not, and
|
|
344
|
+
// its three bare `astro/...` specifiers are treated as app source imports.
|
|
345
|
+
//
|
|
346
|
+
// They are reachable only through the toolbar app entrypoint, which Astro loads
|
|
347
|
+
// by dynamic import from the `astro:toolbar:internal` virtual module — after the
|
|
348
|
+
// scan, after the server is up. Two of them are not in Astro's own include list
|
|
349
|
+
// (`astro/toolbar` is — vite-plugin-dev-toolbar.js `config()`), so they arrive as
|
|
350
|
+
// missing imports and force a re-optimize of a set that already contains
|
|
351
|
+
// `astro/runtime/client/dev-toolbar/entrypoint.js`. The HTML asks for that
|
|
352
|
+
// entrypoint at a URL with no version query, so once its optimized copy is
|
|
353
|
+
// replaced the request can no longer be matched and answers 504 Outdated Optimize
|
|
354
|
+
// Dep — permanently, warm cache included. `customElements.get("astro-dev-toolbar")`
|
|
355
|
+
// stays undefined and ALL FIVE of Astro's own apps die with ours.
|
|
356
|
+
//
|
|
357
|
+
// The lever is `exclude` rather than `include`: `skipOptimization` reads
|
|
358
|
+
// `exclude?.includes(rawId)` — an exact match on the specifier as written — so
|
|
359
|
+
// these two names, and nothing else, stop being optimizer candidates. They then
|
|
360
|
+
// resolve to Astro's own dist files, the same ones the built-in apps use. Naming
|
|
361
|
+
// the package (`exclude: ["astro"]`) would match via `pkgId` too, but it would
|
|
362
|
+
// also swallow `astro/toolbar` and the toolbar entrypoint, both of which Astro
|
|
363
|
+
// deliberately pre-bundles. `include` would be the wrong shape as well: it would
|
|
364
|
+
// pre-bundle a second copy of Astro's highlight/window helpers to cure a problem
|
|
365
|
+
// whose cause is that they were bundled at all.
|
|
366
|
+
optimizeDeps: {
|
|
367
|
+
exclude: [
|
|
368
|
+
"astro/client/dev-toolbar/apps/utils/highlight.js",
|
|
369
|
+
"astro/client/dev-toolbar/apps/utils/window.js",
|
|
370
|
+
],
|
|
371
|
+
},
|
|
372
|
+
// Tickets land OUTSIDE site/, so Vite's watcher should never see them — but a write
|
|
373
|
+
// that reloads the page mid-comment would be the one defect a user notices every
|
|
374
|
+
// single time, so it is ruled out here as well (contract non-negotiable 4).
|
|
375
|
+
server: {
|
|
376
|
+
watch: {
|
|
377
|
+
ignored: [
|
|
378
|
+
`**/${config.backlogDir}/**`,
|
|
379
|
+
join(repoRoot, config.backlogDir, "**"),
|
|
380
|
+
join(config.archiveDir, "**"),
|
|
381
|
+
],
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
logger.info(`pin app on the dev toolbar · tickets → ${config.backlogDir}/tasks · stamping ${config.stamp.join(", ")}`);
|
|
388
|
+
},
|
|
389
|
+
|
|
390
|
+
"astro:server:setup": ({ server, toolbar, logger }) => {
|
|
391
|
+
// Assertion three: this hook does not exist in a build. The write path lives only here,
|
|
392
|
+
// and so does the wall — neither can exist in `dist/`.
|
|
393
|
+
if (!repoRoot) return; // config:setup stood down; nothing to wire
|
|
394
|
+
|
|
395
|
+
const route = boardRoute(config.route);
|
|
396
|
+
const assetPrefix = `${route}/assets/`;
|
|
397
|
+
const apiPath = `${route}/api/ticket`;
|
|
398
|
+
const assetsRoot = join(repoRoot, config.backlogDir, "assets");
|
|
399
|
+
const assetHref = (t) =>
|
|
400
|
+
t.shotName ? assetPrefix + t.shotName.split("/").map(encodeURIComponent).join("/") : null;
|
|
401
|
+
|
|
402
|
+
// The wall, and its screenshots. Registered here rather than as an injected route (see the
|
|
403
|
+
// note at the top of this file), which also puts it ahead of Astro's own request handler:
|
|
404
|
+
// Astro installs that in a Vite post hook, after every integration's middleware is in
|
|
405
|
+
// place, so `/pin` reaches this and never reaches Astro's 404.
|
|
406
|
+
server.middlewares.use((req, res, next) => {
|
|
407
|
+
let url;
|
|
408
|
+
try {
|
|
409
|
+
url = new URL(req.url ?? "/", "http://localhost");
|
|
410
|
+
} catch {
|
|
411
|
+
return next();
|
|
412
|
+
}
|
|
413
|
+
const path = url.pathname;
|
|
414
|
+
const wall = path === route || path === `${route}/`;
|
|
415
|
+
const api = path === apiPath;
|
|
416
|
+
if (!wall && !api && !path.startsWith(assetPrefix)) return next();
|
|
417
|
+
|
|
418
|
+
const json = (code, body) => {
|
|
419
|
+
res.statusCode = code;
|
|
420
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
421
|
+
res.setHeader("Cache-Control", "no-store");
|
|
422
|
+
return res.end(JSON.stringify(body));
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// ── the one write path ───────────────────────────────────────────────────────────
|
|
426
|
+
// Four refusals before a byte of the request body is even parsed, then `updateTicket`,
|
|
427
|
+
// which refuses again on everything it cannot verify. None of them warn; all of them
|
|
428
|
+
// answer with a sentence the board puts on screen.
|
|
429
|
+
if (api) {
|
|
430
|
+
if (req.method !== "POST") {
|
|
431
|
+
res.statusCode = 405;
|
|
432
|
+
res.setHeader("Allow", "POST");
|
|
433
|
+
return res.end("POST");
|
|
434
|
+
}
|
|
435
|
+
// Same-origin. A page on another origin can POST JSON cross-site without a preflight
|
|
436
|
+
// only if it lies about its content type, which the next check refuses — but the
|
|
437
|
+
// browser always sends `Origin` on a cross-site POST, so this is the cheap first no.
|
|
438
|
+
const origin = req.headers.origin;
|
|
439
|
+
if (origin) {
|
|
440
|
+
let sameOrigin = false;
|
|
441
|
+
try {
|
|
442
|
+
sameOrigin = new URL(origin).host === req.headers.host;
|
|
443
|
+
} catch {
|
|
444
|
+
sameOrigin = false;
|
|
445
|
+
}
|
|
446
|
+
if (!sameOrigin) return json(403, { ok: false, error: "cross-origin writes are refused" });
|
|
447
|
+
}
|
|
448
|
+
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
449
|
+
return json(415, { ok: false, error: "send application/json" });
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const chunks = [];
|
|
453
|
+
let size = 0;
|
|
454
|
+
let overflowed = false;
|
|
455
|
+
req.on("data", (c) => {
|
|
456
|
+
size += c.length;
|
|
457
|
+
if (size > 16384) {
|
|
458
|
+
// A ticket change is three short strings. Anything larger is not one.
|
|
459
|
+
overflowed = true;
|
|
460
|
+
req.destroy();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
chunks.push(c);
|
|
464
|
+
});
|
|
465
|
+
req.on("end", () => {
|
|
466
|
+
if (overflowed) return json(413, { ok: false, error: "that request body is far too large for a status change" });
|
|
467
|
+
let change;
|
|
468
|
+
try {
|
|
469
|
+
change = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
470
|
+
} catch {
|
|
471
|
+
return json(400, { ok: false, error: "the request body was not JSON" });
|
|
472
|
+
}
|
|
473
|
+
let result;
|
|
474
|
+
try {
|
|
475
|
+
result = updateTicket(repoRoot, change, { backlogDir: config.backlogDir });
|
|
476
|
+
} catch (e) {
|
|
477
|
+
// 409: the board asked for something the file does not agree with — a stale
|
|
478
|
+
// `expect`, a ticket that moved. Every other refusal is a bad request.
|
|
479
|
+
const msg = oneLine(e);
|
|
480
|
+
logger.warn(`pin write refused: ${msg}`);
|
|
481
|
+
return json(/changed on disk|on disk, not|was out of date/.test(msg) ? 409 : 400, { ok: false, error: msg });
|
|
482
|
+
}
|
|
483
|
+
// Re-read rather than echo: the card that goes back is the card the FILE now draws.
|
|
484
|
+
// `includeCancelled`, because the write that just landed may have BEEN a cancel —
|
|
485
|
+
// without it the ticket would be written correctly and then reported unreadable.
|
|
486
|
+
const { tickets } = collectTickets(repoRoot, { backlogDir: config.backlogDir, includeCancelled: true });
|
|
487
|
+
const fresh = tickets.find((t) => t.id === result.id);
|
|
488
|
+
if (!fresh) return json(500, { ok: false, error: `${result.id} was written but cannot be read back` });
|
|
489
|
+
if (result.changed.length) {
|
|
490
|
+
logger.info(`${result.id} → ${result.changed.map((k) => `${k}=${k === "status" ? result.status : result.priority}`).join(" ")} (${result.file})`);
|
|
491
|
+
}
|
|
492
|
+
return json(200, {
|
|
493
|
+
ok: true,
|
|
494
|
+
id: fresh.id,
|
|
495
|
+
status: fresh.status,
|
|
496
|
+
priority: fresh.priority,
|
|
497
|
+
file: fresh.file,
|
|
498
|
+
changed: result.changed,
|
|
499
|
+
card: renderCard(fresh, { assetHref, live: true }),
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
req.on("error", () => json(400, { ok: false, error: "the request stream failed" }));
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
507
|
+
res.statusCode = 405;
|
|
508
|
+
res.setHeader("Allow", "GET, HEAD");
|
|
509
|
+
return res.end("GET or HEAD");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (wall) {
|
|
513
|
+
// Read from disk on every request — that is the whole point of the route, and why
|
|
514
|
+
// `orbytes-pin-gallery` is no longer part of looking at the board.
|
|
515
|
+
try {
|
|
516
|
+
// `includeCancelled`: `/pin` is the ONE surface where the archive is visible. Every
|
|
517
|
+
// agent-facing read drops cancelled tickets by default — a cancelled ticket must not
|
|
518
|
+
// take up context space for an agent — and an archive nobody can open is a delete.
|
|
519
|
+
const { tickets, broken } = collectTickets(repoRoot, { backlogDir: config.backlogDir, includeCancelled: true });
|
|
520
|
+
const html = renderBoard(tickets, broken, {
|
|
521
|
+
// The presence of an endpoint is what makes the page writable. The standalone
|
|
522
|
+
// file passes none, so it renders the same board with no drag and no menus —
|
|
523
|
+
// there is nothing listening behind a `file://` page.
|
|
524
|
+
apiHref: apiPath,
|
|
525
|
+
// A shot outside `backlog/assets/` has no URL here, because this route serves that
|
|
526
|
+
// one directory and nothing else; the card then shows its placeholder, naming the
|
|
527
|
+
// path it expected. Nothing the picker writes can land outside it.
|
|
528
|
+
assetHref,
|
|
529
|
+
// Where "Site" and "Lab" point. `null` keeps the board's own default pair; the
|
|
530
|
+
// lab passes its resolved subpath so the link is the truth rather than a guess.
|
|
531
|
+
links: config.links,
|
|
532
|
+
});
|
|
533
|
+
res.statusCode = 200;
|
|
534
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
535
|
+
res.setHeader("Cache-Control", "no-store");
|
|
536
|
+
return res.end(req.method === "HEAD" ? undefined : html);
|
|
537
|
+
} catch (e) {
|
|
538
|
+
// A broken TICKET never gets here — collectTickets collects those and the page says
|
|
539
|
+
// so. This is the board directory being unreadable, which is worth saying plainly.
|
|
540
|
+
logger.warn(`${route} failed: ${oneLine(e)}`);
|
|
541
|
+
res.statusCode = 500;
|
|
542
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
543
|
+
return res.end(`orbytes-pin: could not read ${config.backlogDir}/tasks — ${oneLine(e)}\n`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
let rel;
|
|
548
|
+
try {
|
|
549
|
+
rel = decodeURIComponent(path.slice(assetPrefix.length));
|
|
550
|
+
} catch {
|
|
551
|
+
res.statusCode = 400;
|
|
552
|
+
return res.end("bad path");
|
|
553
|
+
}
|
|
554
|
+
const asset = resolveAsset(assetsRoot, rel);
|
|
555
|
+
if (!asset) {
|
|
556
|
+
res.statusCode = 404;
|
|
557
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
558
|
+
return res.end("not found\n");
|
|
559
|
+
}
|
|
560
|
+
res.statusCode = 200;
|
|
561
|
+
res.setHeader("Content-Type", asset.type);
|
|
562
|
+
res.setHeader("Content-Length", String(asset.size));
|
|
563
|
+
// A ticket's PNG is a hardlink that shot.mjs can replace under the same name, so a
|
|
564
|
+
// cached copy would show the previous shot after a re-pin.
|
|
565
|
+
res.setHeader("Cache-Control", "no-store");
|
|
566
|
+
if (req.method === "HEAD") return res.end();
|
|
567
|
+
return createReadStream(asset.file).pipe(res);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
toolbar.on("orbytes-pin:create", (payload) => {
|
|
571
|
+
// ── Synchronous half: the ticket, and the reply. Nothing awaited before send(). ──
|
|
572
|
+
let ticket;
|
|
573
|
+
try {
|
|
574
|
+
ticket = writeTicket(repoRoot, payload, { backlogDir: config.backlogDir });
|
|
575
|
+
} catch (e) {
|
|
576
|
+
logger.warn(`pin refused: ${oneLine(e)}`);
|
|
577
|
+
toolbar.send("orbytes-pin:created", { id: null, file: null, shot: null, error: oneLine(e) });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
toolbar.send("orbytes-pin:created", {
|
|
581
|
+
id: ticket.id,
|
|
582
|
+
file: ticket.file,
|
|
583
|
+
shot: config.shots ? ticket.shot : null,
|
|
584
|
+
error: null,
|
|
585
|
+
});
|
|
586
|
+
logger.info(`${ticket.id} → ${ticket.file} (dispatch: ${payload.dispatch})`);
|
|
587
|
+
|
|
588
|
+
// ── Asynchronous half: the screenshot. The user is already back at work. ──
|
|
589
|
+
if (!config.shots) return;
|
|
590
|
+
const dest = join(config.archiveDir, project, ticket.archiveName);
|
|
591
|
+
const link = join(repoRoot, config.backlogDir, "assets", ticket.archiveName);
|
|
592
|
+
shoot({
|
|
593
|
+
origin: devOrigin(server),
|
|
594
|
+
url: payload.url,
|
|
595
|
+
viewport: payload.viewport,
|
|
596
|
+
scroll: payload.scroll,
|
|
597
|
+
rect: payload.rect,
|
|
598
|
+
dest,
|
|
599
|
+
link,
|
|
600
|
+
// The fresh page this shot loads is the only honest test of whether the selector
|
|
601
|
+
// written a moment ago survives a reload — the picking page cannot answer, because
|
|
602
|
+
// it IS the load the selector was built against. shoot() counts the matches there
|
|
603
|
+
// and writes the number into the ticket; these two arguments are what let it.
|
|
604
|
+
selector: payload.selector,
|
|
605
|
+
ticketFile: ticket.absolute,
|
|
606
|
+
log: (m) => logger.warn(`${ticket.id}: ${m}`),
|
|
607
|
+
})
|
|
608
|
+
.then(({ ms, cold, linked, selectorMatches }) => {
|
|
609
|
+
logger.info(`${ticket.id} shot in ${ms} ms${cold ? " (cold launch)" : ""} → ${ticket.shot}${linked === "copy" ? " (copied, not hardlinked)" : ""}`);
|
|
610
|
+
if (selectorMatches !== 1 && selectorMatches !== null) {
|
|
611
|
+
logger.warn(
|
|
612
|
+
selectorMatches === 0
|
|
613
|
+
? `${ticket.id}: its selector matched NOTHING on a fresh load — the ticket is still workable from its source file, screenshot and outer_html, but not by selector`
|
|
614
|
+
: `${ticket.id}: its selector matched ${selectorMatches} elements on a fresh load — it does not name one element`,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
toolbar.send("orbytes-pin:shot", { id: ticket.id, shot: ticket.shot, selectorMatches, error: null });
|
|
618
|
+
})
|
|
619
|
+
.catch((e) => {
|
|
620
|
+
// The ticket stands. Only the picture is missing, and the ticket says where it
|
|
621
|
+
// would have been — no stale PNG is left behind to read as a success. The ticket's
|
|
622
|
+
// `selector_matches` stays `pending`, which is the truth: nothing was tested.
|
|
623
|
+
logger.warn(`${ticket.id} screenshot failed: ${oneLine(e)}`);
|
|
624
|
+
toolbar.send("orbytes-pin:shot", { id: ticket.id, shot: null, selectorMatches: null, error: oneLine(e) });
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
server.httpServer?.once("close", () => { closeBrowser(); });
|
|
629
|
+
|
|
630
|
+
logger.info(`the pin board is at ${route} · every ticket in ${config.backlogDir}/tasks, live on refresh · drag a card to change its status · agents work "Ready for agent" and stop at "Ready for review"; only you set "Resolved"`);
|
|
631
|
+
// The path comes from `config.archiveDir`, the same value the shot actually writes to —
|
|
632
|
+
// it was a hardcoded `~/.orbytes/feedback-archive/...` until 2026-09-22, so a site that
|
|
633
|
+
// set `archiveDir` was told at every startup that its screenshots were somewhere they had
|
|
634
|
+
// never been. `~` is restored only when the directory really is under $HOME.
|
|
635
|
+
const home = homedir();
|
|
636
|
+
const archiveHere = join(config.archiveDir, project);
|
|
637
|
+
const shown = archiveHere.startsWith(home + sep) ? `~${archiveHere.slice(home.length)}` : archiveHere;
|
|
638
|
+
logger.info(`listening for orbytes-pin:create (dev only) · screenshots ${config.shots ? `→ ${shown}` : "disabled"}`);
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export { findRepoRoot, listTickets, parseTicket, writeTicket } from "./tickets.mjs";
|
|
645
|
+
// The status vocabulary, and the three questions everything asks of a status. An agent picking
|
|
646
|
+
// work off the board asks `isAgentReady` — not "is it open", and certainly not `=== "To Do"`,
|
|
647
|
+
// which no longer exists. `listTickets` already drops `Cancelled` before an agent sees it.
|
|
648
|
+
export {
|
|
649
|
+
BACKLOG,
|
|
650
|
+
CANCELLED,
|
|
651
|
+
CLOSED_STATUSES,
|
|
652
|
+
DEFAULT_STATUS,
|
|
653
|
+
IN_PROGRESS,
|
|
654
|
+
OPEN_STATUSES,
|
|
655
|
+
READY_FOR_AGENT,
|
|
656
|
+
READY_FOR_REVIEW,
|
|
657
|
+
RESOLVED,
|
|
658
|
+
STATUSES,
|
|
659
|
+
isAgentReady,
|
|
660
|
+
isCancelled,
|
|
661
|
+
isOpen,
|
|
662
|
+
normaliseStatus,
|
|
663
|
+
} from "./tickets.mjs";
|
|
664
|
+
export { sourceStampPlugin, stampSource } from "./source-stamp.mjs";
|
|
665
|
+
export { close as closeBrowser, shoot } from "./shot.mjs";
|
|
666
|
+
export { BOARD_CONVENTIONS, PRIORITIES, collectTickets, renderBoard, renderCard, updateTicket } from "./board.mjs";
|