@selvakumaresra/specship 0.3.0 → 0.5.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/README.md +11 -1
- package/commands/ss-brainstorm.md +68 -0
- package/commands/ss-design-implement.md +5 -0
- package/commands/ss-design-loop.md +125 -0
- package/dist/analytics/specship-impact.d.ts +72 -0
- package/dist/analytics/specship-impact.d.ts.map +1 -0
- package/dist/analytics/specship-impact.js +216 -0
- package/dist/analytics/specship-impact.js.map +1 -0
- package/dist/bin/specship.js +70 -4
- package/dist/bin/specship.js.map +1 -1
- package/dist/db/migrations.d.ts +1 -1
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/migrations.js +15 -1
- package/dist/db/migrations.js.map +1 -1
- package/dist/db/schema.sql +8 -0
- package/dist/designer/artifact-store.js +54 -0
- package/dist/designer/browser.js +141 -0
- package/dist/designer/cdp-ensure.js +60 -0
- package/dist/designer/cdp-env.js +18 -0
- package/dist/designer/cdp-trace.js +599 -0
- package/dist/designer/cross-platform.js +74 -0
- package/dist/designer/designer-controller.js +1413 -0
- package/dist/designer/file-panel.js +39 -0
- package/dist/designer/interstitials.js +97 -0
- package/dist/designer/oopif-reader.js +176 -0
- package/dist/designer/package-meta.js +18 -0
- package/dist/designer/preview-host.js +50 -0
- package/dist/designer/repo-root.js +31 -0
- package/dist/designer/run-state.js +353 -0
- package/dist/designer/session-store.js +59 -0
- package/dist/designer/ui-anchors.js +651 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -1
- package/dist/installer/index.d.ts +7 -2
- package/dist/installer/index.d.ts.map +1 -1
- package/dist/installer/index.js +3 -2
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/instructions-template.d.ts +17 -0
- package/dist/installer/instructions-template.d.ts.map +1 -1
- package/dist/installer/instructions-template.js +31 -1
- package/dist/installer/instructions-template.js.map +1 -1
- package/dist/installer/targets/claude.d.ts +19 -0
- package/dist/installer/targets/claude.d.ts.map +1 -1
- package/dist/installer/targets/claude.js +100 -1
- package/dist/installer/targets/claude.js.map +1 -1
- package/dist/installer/targets/shared.d.ts +14 -0
- package/dist/installer/targets/shared.d.ts.map +1 -1
- package/dist/installer/targets/shared.js +49 -0
- package/dist/installer/targets/shared.js.map +1 -1
- package/dist/installer/targets/types.d.ts +8 -0
- package/dist/installer/targets/types.d.ts.map +1 -1
- package/dist/mcp/designer-tools.d.ts +33 -0
- package/dist/mcp/designer-tools.d.ts.map +1 -0
- package/dist/mcp/designer-tools.js +313 -0
- package/dist/mcp/designer-tools.js.map +1 -0
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +22 -1
- package/dist/mcp/tools.js.map +1 -1
- package/dist/server/ingest/impact-backfill.js +69 -0
- package/dist/server/ingest/impact-query.js +343 -0
- package/dist/server/ingest/index.js +2 -1
- package/dist/server/ingest/ingestor.js +41 -6
- package/dist/server/ingest/specship-classify.js +153 -0
- package/dist/server/routes/claude.js +32 -0
- package/dist/server/routes/spec.js +94 -0
- package/dist/server/server.js +26 -2
- package/dist/web/{chunk-JN6W7HCN.js → chunk-45QHGCB4.js} +1 -1
- package/dist/web/{chunk-RAAMPHPJ.js → chunk-A5R3MJMO.js} +1 -1
- package/dist/web/{chunk-2DHIGIOI.js → chunk-ASZ77FMZ.js} +1 -1
- package/dist/web/chunk-D5OCNEJA.js +2 -0
- package/dist/web/{chunk-3SEJX2BK.js → chunk-FHZHD2ZG.js} +1 -1
- package/dist/web/chunk-GR72OOCN.js +1 -0
- package/dist/web/{chunk-YAWCRPHV.js → chunk-NZEZCT65.js} +1 -1
- package/dist/web/chunk-O7434ZMN.js +1 -0
- package/dist/web/chunk-ODX6CT3I.js +6 -0
- package/dist/web/chunk-RASJHUXS.js +1 -0
- package/dist/web/chunk-TQ3P2QZO.js +1 -0
- package/dist/web/{chunk-BCZM5AXU.js → chunk-UBOZGQNK.js} +1 -1
- package/dist/web/chunk-WCHGDXWC.js +1 -0
- package/dist/web/{chunk-BPECIDVO.js → chunk-WCKHQIYN.js} +1 -1
- package/dist/web/{chunk-JFYVCXK3.js → chunk-WLIMNDS3.js} +1 -1
- package/dist/web/{chunk-LV4G6QFG.js → chunk-YAMRN47K.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/main-X2KCYXZ4.js +1 -0
- package/dist/web/sw.js +69 -0
- package/dist/workflows/defaults/claude-design-implement.yaml +138 -49
- package/hooks/hooks.json +11 -0
- package/package.json +7 -3
- package/selectors.json +41 -0
- package/dist/web/chunk-2OKMB4KX.js +0 -2
- package/dist/web/chunk-4N5DWG46.js +0 -1
- package/dist/web/chunk-DA6SNNAF.js +0 -1
- package/dist/web/chunk-JT7P3DEK.js +0 -6
- package/dist/web/chunk-TWXZK6XM.js +0 -1
- package/dist/web/main-WVI3YTDU.js +0 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OPEN_FILES_PANEL_EXPR = void 0;
|
|
4
|
+
// Opener for claude.ai/design's "Design Files" panel, shared by BOTH the live
|
|
5
|
+
// listFilesDetailed scrape (designer-controller) and the session.fileListScrape
|
|
6
|
+
// health anchor (ui-anchors) — one source so the probe exercises the exact path
|
|
7
|
+
// production runs. They diverged once and the probe went green while production
|
|
8
|
+
// silently no-op'd (PR #77 Codex P2); a shared constant prevents a repeat.
|
|
9
|
+
//
|
|
10
|
+
// Open-state is decided ONLY from the trigger's aria-expanded — the one reliable
|
|
11
|
+
// signal. Two tempting alternatives are dead ends Codex walked us through (PR #77):
|
|
12
|
+
// - a blind label.click() TOGGLES an already-open panel shut, and iterate()
|
|
13
|
+
// calls listFiles() before+after a generation, so the second scrape reads the
|
|
14
|
+
// bare page and corrupts newFiles/removedFiles;
|
|
15
|
+
// - counting filename-like body text to infer "already open" can't tell a real
|
|
16
|
+
// panel row from a filename mentioned in a chat turn (index.html), so it
|
|
17
|
+
// skips opening and scrapes incidental text.
|
|
18
|
+
// So: if the trigger exposes aria-expanded, obey it (open iff 'false'). If it
|
|
19
|
+
// does NOT, leave the panel as-is — never a blind toggle, never a body-text
|
|
20
|
+
// guess; the scrape then reads whatever is already visible (the long-standing
|
|
21
|
+
// behavior). React attaches handlers via root delegation, so element.onclick is
|
|
22
|
+
// null on the trigger — we click the label (the event bubbles to the delegated
|
|
23
|
+
// handler) rather than walking up for a non-null .onclick (which never fires).
|
|
24
|
+
//
|
|
25
|
+
// Returns true when the "Design Files" label is present (opened or already-open
|
|
26
|
+
// or left-as-is), false when it isn't found.
|
|
27
|
+
exports.OPEN_FILES_PANEL_EXPR = `(() => {
|
|
28
|
+
const spans = Array.from(document.querySelectorAll('span'));
|
|
29
|
+
const label = spans.find((s) => s.children.length === 0 && (s.textContent || '').trim() === 'Design Files');
|
|
30
|
+
if (!label) return false;
|
|
31
|
+
let t = label;
|
|
32
|
+
for (let i = 0; i < 4 && t; i++) {
|
|
33
|
+
const ex = t.getAttribute && t.getAttribute('aria-expanded');
|
|
34
|
+
if (ex === 'true') return true;
|
|
35
|
+
if (ex === 'false') { label.click(); return true; }
|
|
36
|
+
t = t.parentElement;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
})()`;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Interstitial detection for claude.ai/design.
|
|
3
|
+
//
|
|
4
|
+
// The 2026-06 design UI interrupts the automated flow with content-only overlays
|
|
5
|
+
// that carry no stable data-testid or ARIA role — they can only be matched by
|
|
6
|
+
// their visible content. Each verb runs a pre-flight (DesignerController.
|
|
7
|
+
// clearInterstitials, wired through ensureReady) so automation doesn't silently
|
|
8
|
+
// stall on a banner, misread a frozen view as "done", or call a transient error
|
|
9
|
+
// a context ceiling. Captured live 2026-06-19 against Chrome 149.
|
|
10
|
+
//
|
|
11
|
+
// Detection lives here — pure and unit-tested — so the copy heuristics have one
|
|
12
|
+
// source of truth; the DOM/CDP glue (probe + click/reload/wait) stays in the
|
|
13
|
+
// controller. This mirrors preview-host.ts / run-state.ts: classify in a tested
|
|
14
|
+
// module, act in the controller.
|
|
15
|
+
//
|
|
16
|
+
// STRUCTURAL GUARD (review #1): the two TAKEOVER kinds (cloudflare,
|
|
17
|
+
// transient-error) replace the whole app shell, so they're classified ONLY when
|
|
18
|
+
// the shell is gone — `appShellPresent === false`. Without this, a design session
|
|
19
|
+
// whose chat transcript mentions "verify you are human" (an auth/CAPTCHA mockup)
|
|
20
|
+
// or "something went wrong … try again" (a Claude apology) would be misread as a
|
|
21
|
+
// real overlay and hard-block every verb. The token banner is benign (it sits
|
|
22
|
+
// over a live shell) and is gated on its own action button instead.
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.CONTINUE_HERE_TEXT = exports.TOKEN_BANNER_RE = exports.TRANSIENT_ERROR_BUTTON_RE = exports.TRANSIENT_ERROR_RE = exports.CLOUDFLARE_RE = exports.INTERSTITIAL_PROBE_EXPR = void 0;
|
|
25
|
+
exports.classifyInterstitial = classifyInterstitial;
|
|
26
|
+
exports.plannedAction = plannedAction;
|
|
27
|
+
exports.isBlockingInterstitial = isBlockingInterstitial;
|
|
28
|
+
// A single DOM read producing the InterstitialProbe shape. Exported as the ONE
|
|
29
|
+
// source so the live pre-flight (designer-controller) and the CI diagnostic
|
|
30
|
+
// (ci-health) probe identically — including the appShellPresent guard (review
|
|
31
|
+
// #5 / below-gate dedup). Evaluated via browser.evalValue.
|
|
32
|
+
exports.INTERSTITIAL_PROBE_EXPR = `(() => ({
|
|
33
|
+
bodyText: ((document.body && document.body.innerText) || '').slice(0, 20000),
|
|
34
|
+
buttonTexts: Array.from(document.querySelectorAll('button'))
|
|
35
|
+
.map((b) => (b.textContent || '').trim())
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.slice(0, 300),
|
|
38
|
+
appShellPresent: !!document.querySelector('[data-testid="chat-composer-input"], [data-testid="chat-messages"]')
|
|
39
|
+
}))()`;
|
|
40
|
+
// Cloudflare bot-check / "verify you are human" takeover. The most blocking of
|
|
41
|
+
// the three — it replaces the app shell and can't be auto-solved, only waited
|
|
42
|
+
// out (it often self-clears) or handed to a human.
|
|
43
|
+
exports.CLOUDFLARE_RE = /verify you are human|performing security verification|review the security of your connection|checking your browser before|needs to review the security/i;
|
|
44
|
+
// Transient "Something went wrong" error page. Gated on app-shell-absence AND a
|
|
45
|
+
// real action button (review #1) so an inline "something went wrong" string in
|
|
46
|
+
// the transcript can't trip a reload storm.
|
|
47
|
+
exports.TRANSIENT_ERROR_RE = /something went wrong/i;
|
|
48
|
+
exports.TRANSIENT_ERROR_BUTTON_RE = /^(try again|back to projects)$/i;
|
|
49
|
+
// Context-save nudge: "Start a new chat to save 483k tokens of context" with
|
|
50
|
+
// New chat / Continue here. We click "Continue here" to keep the session's
|
|
51
|
+
// context — "New chat" would discard it.
|
|
52
|
+
exports.TOKEN_BANNER_RE = /start a new chat to save\b|save \d+k tokens of context/i;
|
|
53
|
+
exports.CONTINUE_HERE_TEXT = 'Continue here';
|
|
54
|
+
function hasButtonText(buttonTexts, text) {
|
|
55
|
+
const want = text.trim().toLowerCase();
|
|
56
|
+
return buttonTexts.some((t) => (t || '').trim().toLowerCase() === want);
|
|
57
|
+
}
|
|
58
|
+
function hasButtonMatching(buttonTexts, re) {
|
|
59
|
+
return buttonTexts.some((t) => re.test((t || '').trim()));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Classify the most pressing interstitial present, or null when the page is
|
|
63
|
+
* clear. Order is by severity: a Cloudflare takeover hides everything beneath
|
|
64
|
+
* it, so it's checked first; the token banner is the most benign (the composer
|
|
65
|
+
* stays usable beneath it) so it's checked last. Takeover kinds require the app
|
|
66
|
+
* shell to be ABSENT (see module header); the token banner requires its action
|
|
67
|
+
* button to be present (the phrase alone — e.g. echoed in chat — is not enough).
|
|
68
|
+
*/
|
|
69
|
+
function classifyInterstitial(probe, opts = {}) {
|
|
70
|
+
const body = probe.bodyText || '';
|
|
71
|
+
const buttons = probe.buttonTexts || [];
|
|
72
|
+
if (!probe.appShellPresent) {
|
|
73
|
+
if (exports.CLOUDFLARE_RE.test(body))
|
|
74
|
+
return 'cloudflare';
|
|
75
|
+
if (exports.TRANSIENT_ERROR_RE.test(body) && hasButtonMatching(buttons, exports.TRANSIENT_ERROR_BUTTON_RE))
|
|
76
|
+
return 'transient-error';
|
|
77
|
+
}
|
|
78
|
+
if (exports.TOKEN_BANNER_RE.test(body) && hasButtonText(buttons, opts.continueHere || exports.CONTINUE_HERE_TEXT))
|
|
79
|
+
return 'token-banner';
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
/** The handling strategy for each interstitial kind. */
|
|
83
|
+
function plannedAction(kind) {
|
|
84
|
+
switch (kind) {
|
|
85
|
+
case 'token-banner':
|
|
86
|
+
return 'click-continue';
|
|
87
|
+
case 'transient-error':
|
|
88
|
+
return 'reload';
|
|
89
|
+
case 'cloudflare':
|
|
90
|
+
return 'await-human';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Blocking interstitials make a verb impossible; the token banner does not
|
|
94
|
+
* (the shell stays usable beneath it), so a residual one is never fatal. */
|
|
95
|
+
function isBlockingInterstitial(kind) {
|
|
96
|
+
return kind !== 'token-banner';
|
|
97
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OopifHtmlReader = void 0;
|
|
4
|
+
exports.captureOopifHtml = captureOopifHtml;
|
|
5
|
+
const cdp_trace_1 = require("./cdp-trace");
|
|
6
|
+
const preview_host_1 = require("./preview-host");
|
|
7
|
+
function evaluatedString(result) {
|
|
8
|
+
const rec = (0, cdp_trace_1.asRec)(result);
|
|
9
|
+
// An exceptionDetails means the evaluate threw — treat as no value.
|
|
10
|
+
if (rec.exceptionDetails)
|
|
11
|
+
return null;
|
|
12
|
+
const inner = (0, cdp_trace_1.asRec)(rec.result);
|
|
13
|
+
return typeof inner.value === 'string' && inner.value.length > 0 ? inner.value : null;
|
|
14
|
+
}
|
|
15
|
+
// Pick the preview child. The project renders its preview in a SINGLE OOPIF, so a
|
|
16
|
+
// unique preview-host child is unambiguously THE preview. Zero, or many (e.g. old
|
|
17
|
+
// and new preview coexisting during a file switch), -> null, so we never serve an
|
|
18
|
+
// arbitrary or stale frame as the requested file (PR #67 review).
|
|
19
|
+
//
|
|
20
|
+
// NOTE (live-verified): the OOPIF *document* URL is per-file
|
|
21
|
+
// (`…claudeusercontent.com/.../serve/<filename>?…`) — the `_bootstrap` is only the
|
|
22
|
+
// iframe element's src; the loader navigates the frame to the real serve URL. So
|
|
23
|
+
// matching the child against getIframeSrc() (`_bootstrap`) is wrong; host+uniqueness
|
|
24
|
+
// is the correct signal. A future multi-preview disambiguation can match the active
|
|
25
|
+
// file via that `/serve/<filename>` path, but single-preview is the steady state.
|
|
26
|
+
function pickPreviewChild(children, isPreviewUrl) {
|
|
27
|
+
// type === 'iframe' guards against a same-origin worker/service-worker on
|
|
28
|
+
// claudeusercontent.com counting as a second "preview" and flooring the read to
|
|
29
|
+
// null (#67 review) — the preview is an iframe document, not a worker target.
|
|
30
|
+
const previews = children.filter((c) => typeof c.url === 'string' && c.type === 'iframe' && isPreviewUrl(c.url));
|
|
31
|
+
const only = previews[0];
|
|
32
|
+
return previews.length === 1 && only ? only : null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* PURE orchestrator for reading a cross-origin preview OOPIF's rendered HTML.
|
|
36
|
+
* Owns the CDP command sequence but not the socket; every call is timeout-bounded
|
|
37
|
+
* and bounded polling uses opts.now. Returns the serialized outerHTML string, or
|
|
38
|
+
* null on no-match / no-value / timeout / any throw — never throws, never hangs.
|
|
39
|
+
*/
|
|
40
|
+
async function captureOopifHtml(send, opts) {
|
|
41
|
+
const now = opts.now ?? (() => Date.now());
|
|
42
|
+
const waitForAttachMs = opts.waitForAttachMs ?? 1500;
|
|
43
|
+
const pollMs = opts.pollMs ?? 25;
|
|
44
|
+
const sendTimeoutMs = opts.sendTimeoutMs ?? 4000;
|
|
45
|
+
// Bound every CDP call: a live-but-silent socket must degrade to null, not hang
|
|
46
|
+
// forever (PR #67 review). A synchronous throw from `send` becomes a rejection.
|
|
47
|
+
const sendBounded = (method, params, sessionId) => {
|
|
48
|
+
let timer;
|
|
49
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
50
|
+
timer = setTimeout(() => reject(new Error('cdp-send-timeout')), sendTimeoutMs);
|
|
51
|
+
});
|
|
52
|
+
const call = (async () => send(method, params, sessionId))();
|
|
53
|
+
return Promise.race([call, timeout]).finally(() => clearTimeout(timer));
|
|
54
|
+
};
|
|
55
|
+
let armed = false;
|
|
56
|
+
try {
|
|
57
|
+
// (a) arm auto-attach. flatten:true is LOAD-BEARING — without it Chrome routes
|
|
58
|
+
// child traffic via the deprecated nested sendMessageToTarget the base send()
|
|
59
|
+
// does not speak. waitForDebuggerOnStart:false so OOPIFs aren't paused.
|
|
60
|
+
await sendBounded('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true });
|
|
61
|
+
armed = true;
|
|
62
|
+
// (b) bounded-poll the injected snapshot for the unique preview child.
|
|
63
|
+
const deadline = now() + waitForAttachMs;
|
|
64
|
+
let child = pickPreviewChild(opts.attachedTargets(), opts.isPreviewUrl);
|
|
65
|
+
while (!child && now() < deadline) {
|
|
66
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
67
|
+
child = pickPreviewChild(opts.attachedTargets(), opts.isPreviewUrl);
|
|
68
|
+
}
|
|
69
|
+
if (!child)
|
|
70
|
+
return null;
|
|
71
|
+
// (c) PRIMARY: DOM serialization on the CHILD session. No page-world execution
|
|
72
|
+
// context to depend on or be spoofed by; DOM.getDocument implicitly enables the
|
|
73
|
+
// DOM domain, and depth:0 fetches just the root node before getOuterHTML
|
|
74
|
+
// serializes the tree.
|
|
75
|
+
try {
|
|
76
|
+
const doc = (0, cdp_trace_1.asRec)(await sendBounded('DOM.getDocument', { depth: 0, pierce: false }, child.sessionId));
|
|
77
|
+
const root = (0, cdp_trace_1.asRec)(doc.root);
|
|
78
|
+
const nodeId = typeof root.nodeId === 'number' ? root.nodeId : null;
|
|
79
|
+
if (nodeId !== null) {
|
|
80
|
+
const outer = (0, cdp_trace_1.asRec)(await sendBounded('DOM.getOuterHTML', { nodeId }, child.sessionId));
|
|
81
|
+
if (typeof outer.outerHTML === 'string' && outer.outerHTML.length > 0)
|
|
82
|
+
return outer.outerHTML;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// fall through to the Runtime fallback
|
|
87
|
+
}
|
|
88
|
+
// (d) FALLBACK: page-world Runtime.evaluate on the CHILD sessionId (without it
|
|
89
|
+
// the eval runs in the parent page and returns the shell). For builds/states
|
|
90
|
+
// where the DOM route returns nothing.
|
|
91
|
+
const evalRes = await sendBounded('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true, awaitPromise: false }, child.sessionId);
|
|
92
|
+
return evaluatedString(evalRes);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
// (e) bounded, self-contained teardown — never let cleanup throw or hang.
|
|
99
|
+
if (armed) {
|
|
100
|
+
try {
|
|
101
|
+
await sendBounded('Target.setAutoAttach', { autoAttach: false, waitForDebuggerOnStart: false, flatten: true });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* best-effort */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
class OopifHtmlReader extends cdp_trace_1.CdpSession {
|
|
110
|
+
// Keyed by sessionId; each entry also carries targetId for targetInfoChanged.
|
|
111
|
+
childTargets = new Map();
|
|
112
|
+
constructor(ws, target, opts = {}) {
|
|
113
|
+
// One-shot reader: PIN reconnect:false (a mid-capture gap degrades to null ->
|
|
114
|
+
// node-fetch fallback; there is no one-shot terminal to recover). Pinned last
|
|
115
|
+
// so a caller can't accidentally route the base reconnect path into the no-op
|
|
116
|
+
// enableDomains() (#67 review, below-gate).
|
|
117
|
+
super(ws, target, { ...opts, reconnect: false });
|
|
118
|
+
}
|
|
119
|
+
static async attach(opts = {}) {
|
|
120
|
+
if (typeof WebSocket === 'undefined')
|
|
121
|
+
return null;
|
|
122
|
+
try {
|
|
123
|
+
const { ws, target } = await OopifHtmlReader.connectTarget(opts);
|
|
124
|
+
return new OopifHtmlReader(ws, target, opts);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// No Network.enable for a one-shot OOPIF read; captureOopifHtml issues the
|
|
131
|
+
// Target.setAutoAttach itself so the pure unit owns the full sequence.
|
|
132
|
+
async enableDomains() { }
|
|
133
|
+
onEvent(method, params, _sessionId) {
|
|
134
|
+
const p = (0, cdp_trace_1.asRec)(params);
|
|
135
|
+
if (method === 'Target.attachedToTarget') {
|
|
136
|
+
const sessionId = typeof p.sessionId === 'string' ? p.sessionId : '';
|
|
137
|
+
const info = (0, cdp_trace_1.asRec)(p.targetInfo);
|
|
138
|
+
const url = typeof info.url === 'string' ? info.url : '';
|
|
139
|
+
const type = typeof info.type === 'string' ? info.type : '';
|
|
140
|
+
const targetId = typeof info.targetId === 'string' ? info.targetId : undefined;
|
|
141
|
+
if (sessionId)
|
|
142
|
+
this.childTargets.set(sessionId, { sessionId, url, type, targetId });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (method === 'Target.targetInfoChanged') {
|
|
146
|
+
// A child can attach as about:blank then navigate to _bootstrap (would be a
|
|
147
|
+
// false null), or attach as _bootstrap then navigate away (would be stale
|
|
148
|
+
// wrong-content). Keep the stored URL current, correlated by targetId (#67).
|
|
149
|
+
const info = (0, cdp_trace_1.asRec)(p.targetInfo);
|
|
150
|
+
const targetId = typeof info.targetId === 'string' ? info.targetId : '';
|
|
151
|
+
if (!targetId)
|
|
152
|
+
return;
|
|
153
|
+
for (const child of this.childTargets.values()) {
|
|
154
|
+
if (child.targetId === targetId) {
|
|
155
|
+
if (typeof info.url === 'string')
|
|
156
|
+
child.url = info.url;
|
|
157
|
+
if (typeof info.type === 'string')
|
|
158
|
+
child.type = info.type;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (method === 'Target.detachedFromTarget') {
|
|
164
|
+
const sessionId = typeof p.sessionId === 'string' ? p.sessionId : '';
|
|
165
|
+
if (sessionId)
|
|
166
|
+
this.childTargets.delete(sessionId);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async readPreviewHtml() {
|
|
170
|
+
return captureOopifHtml(this.send.bind(this), {
|
|
171
|
+
attachedTargets: () => [...this.childTargets.values()],
|
|
172
|
+
isPreviewUrl: preview_host_1.isPreviewIframeSrc
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
exports.OopifHtmlReader = OopifHtmlReader;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.PACKAGE_VERSION = exports.PACKAGE_METADATA = void 0;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const repo_root_1 = require("./repo-root");
|
|
10
|
+
function readPackageMetadata() {
|
|
11
|
+
const raw = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(repo_root_1.REPO_ROOT, 'package.json'), 'utf8'));
|
|
12
|
+
return {
|
|
13
|
+
name: typeof raw.name === 'string' ? raw.name : 'designer',
|
|
14
|
+
version: typeof raw.version === 'string' ? raw.version : '0.0.0'
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
exports.PACKAGE_METADATA = readPackageMetadata();
|
|
18
|
+
exports.PACKAGE_VERSION = exports.PACKAGE_METADATA.version;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isPreviewIframeSrc = isPreviewIframeSrc;
|
|
4
|
+
exports.previewIframeVariant = previewIframeVariant;
|
|
5
|
+
exports.isBootstrapShellHtml = isBootstrapShellHtml;
|
|
6
|
+
// The design preview iframe is a load-bearing invariant: it must serve from
|
|
7
|
+
// claudeusercontent.com. How it's addressed has drifted — the legacy form was
|
|
8
|
+
// `claudeusercontent.com/...?t=<signed-token>`; the 2026-06 redesign (issue #61)
|
|
9
|
+
// moved it to a per-project `<uuid>.claudeusercontent.com/_bootstrap...` subdomain
|
|
10
|
+
// with no token. Assert the HOST (real drift = the preview leaving that domain),
|
|
11
|
+
// not a substring — a substring match would wave through a suffix-attached host
|
|
12
|
+
// (`claudeusercontent.com.evil.test`) or a query/path mention
|
|
13
|
+
// (`evil.test/?u=claudeusercontent.com`), defeating the drift anchor this backs.
|
|
14
|
+
function isPreviewIframeSrc(src) {
|
|
15
|
+
let host;
|
|
16
|
+
try {
|
|
17
|
+
host = new URL(src).hostname;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
return host === 'claudeusercontent.com' || host.endsWith('.claudeusercontent.com');
|
|
23
|
+
}
|
|
24
|
+
// Which addressing scheme the (already host-validated) preview src uses. Reported
|
|
25
|
+
// in the health anchor's detail so drift between the legacy signed-token form and
|
|
26
|
+
// the current per-project bootstrap subdomain stays legible.
|
|
27
|
+
function previewIframeVariant(src) {
|
|
28
|
+
if (/[?&]t=/.test(src))
|
|
29
|
+
return 'signed-token';
|
|
30
|
+
if (/\/_bootstrap/.test(src))
|
|
31
|
+
return 'bootstrap-subdomain';
|
|
32
|
+
return 'other';
|
|
33
|
+
}
|
|
34
|
+
// True when `html` is the unauthenticated ~1.1KB loader shell the bootstrap
|
|
35
|
+
// iframe serves to the parent origin (not the rendered design). A node fetch of a
|
|
36
|
+
// bootstrap-subdomain src always returns this shell; the real DOM only exists in
|
|
37
|
+
// the cross-origin OOPIF (see oopif-reader.ts). The shell's stable signature is
|
|
38
|
+
// its postMessage init handshake ('omelette-preview-init') — class/markup hashes
|
|
39
|
+
// drift, that string does not. Used to assert the OOPIF read returned rendered
|
|
40
|
+
// content, not the loader, and to defend callers against saving a shell as the
|
|
41
|
+
// captured artifact. Empty / non-shell HTML → false (don't misread "no sample").
|
|
42
|
+
//
|
|
43
|
+
// Size-bounded (review below-gate): a rendered design that legitimately
|
|
44
|
+
// DOCUMENTS the preview protocol could contain the marker string, so require the
|
|
45
|
+
// document also be loader-sized. The real shell is ~1.1KB; any rendered design is
|
|
46
|
+
// far larger, so the marker + a sub-4KB body is an unambiguous shell signal.
|
|
47
|
+
const BOOTSTRAP_SHELL_MAX_BYTES = 4000;
|
|
48
|
+
function isBootstrapShellHtml(html) {
|
|
49
|
+
return typeof html === 'string' && html.length < BOOTSTRAP_SHELL_MAX_BYTES && html.includes('omelette-preview-init');
|
|
50
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.REPO_ROOT = void 0;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
// Walk up from this file's location until we find package.json. Lets the
|
|
10
|
+
// vendored designer subtree locate its shipped resource (selectors.json) at
|
|
11
|
+
// the SpecShip install root regardless of layout:
|
|
12
|
+
// - source mode: repo-root.ts at src/designer/ → walks up to the repo root.
|
|
13
|
+
// - compiled mode (tsc → dist/designer/): repo-root.js → walks up past
|
|
14
|
+
// dist/ to the dir holding SpecShip's package.json (the install root).
|
|
15
|
+
//
|
|
16
|
+
// Uses CommonJS `__dirname` — this subtree compiles to CJS under
|
|
17
|
+
// src/designer/tsconfig.json, so there is no `import.meta` to reconcile with
|
|
18
|
+
// SpecShip's `module: commonjs` toolchain.
|
|
19
|
+
function findRepoRoot() {
|
|
20
|
+
let dir = __dirname;
|
|
21
|
+
for (let i = 0; i < 8; i++) {
|
|
22
|
+
if (node_fs_1.default.existsSync(node_path_1.default.join(dir, 'package.json')))
|
|
23
|
+
return dir;
|
|
24
|
+
const parent = node_path_1.default.dirname(dir);
|
|
25
|
+
if (parent === dir)
|
|
26
|
+
break;
|
|
27
|
+
dir = parent;
|
|
28
|
+
}
|
|
29
|
+
throw new Error('repo-root: could not find package.json walking up from ' + __dirname);
|
|
30
|
+
}
|
|
31
|
+
exports.REPO_ROOT = findRepoRoot();
|