qwen-code/scripts/copy_bundle_assets.js
tanzhenxin b7543aeb1b
feat(browser-use): add Chrome Native Messaging relay (#11242)
* feat(browser-use): add Playwright browser SDK

* docs(browser-use): document browser use architecture

* fix(browser-use): make browser input actions reliable

* feat(browser-use): preserve screenshot coordinate metadata

* fix(browser-use): recover stale sockets across VM realms

* fix(browser-use): bound locator action retries

* fix(browser-use): keep page input in the background

* fix(browser-use): reject ambiguous input arguments

* fix(browser-use): validate locator composition identity

* fix(browser-use): recover socket locks across VM realms

* fix(browser-use): preserve input results across navigation

* fix(browser-use): capture screenshots through bounded CDP frames

* fix(browser-use): separate input and navigation waits

* feat(browser-use): add Chrome native relay

* fix(chrome-extension): avoid foreground tabs and stale cursor markup

* fix(chrome-extension): require Chrome 125 for flat sessions

* feat(browser-use): add product integration

* test(browser-use): add managed Chrome smoke coverage

* feat(node-repl): expose structured image metadata

* test(browser-use): preserve complete smoke reports

* feat(browser-use): bundle skill and runtime with CLI

* fix(browser-use): install pinned Playwright in fresh workspaces

* feat(browser-use): require explicit skill opt-in

* feat(browser-use): make bundled skill available by default

Keep explicit skill disables authoritative, update documentation and tests, and use the latest Node REPL package in setup instructions.

* fix(ci): build browser-use before Live Host publish

Build the bundled browser runtime before core in the Live Host npm dependency chain, and cover that prerequisite order with a regression test.

* fix(browser-use): output screenshot metadata explicitly

* fix(node-repl): preserve image metadata outside prose budget

* fix(browser-use): address review round on workspace wiring and runtime correctness

- register the workspace in .pnpmfile.mjs, pnpm-lock.yaml, and the
  unit-vitest-configs registry, with the shared-pool timeout ternary and
  off-Linux unhandled-error exemption (fixes the red Install/Test checks)
- map Playwright's 'warning' console type to the contract's 'warn' level
- reject captures whose pixel dimensions contradict the css-pixels
  coordinate contract instead of certifying rescaled images
- keep one unattachable derived tab from failing tabs.list while still
  propagating a lost session
- clear the cached dialog when Chrome resolves it out of band or the page
  navigates, guarded by dialog identity so a newer dialog survives
- synthesize a parseable Browser.getVersion (numeric Chrome major, host
  platform token) so Playwright enables the right keyboard editing commands
- keep YAML-quoted snapshot keys and link props in interactive listings and
  stop claiming subtrees that filtering removed
- always release the mouse button when a drag gesture fails

* fix(dev): build browser-use runtime before startup

* fix(browser-use): isolate cleanup and correct smoke checks

* fix(browser-use): resolve managed Chrome through Playwright

* fix(browser-use): harden native relay and tab lifecycle

* fix(browser-use): drain host output and persist restored tab state

* fix(browser-use): scope dialog handles and clean navigation waiters

* fix(browser-use): preserve input outcomes and release modifiers

* fix(browser-use): drain session teardown and release debugger attachments

* fix(browser-use): correct locator validation and evaluation

* fix(dev): reuse prebuilt Browser Use runtime

* fix(browser-use): address review round on locator, bridge, and screenshot correctness

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(browser-use): require installed extension before native host setup

* fix(browser-use): address review round on schema probe bound and regex validation

* fix(browser-use): start transport before Chrome preflight

* fix(browser-use): exclude staged runtime from code checks

* fix(core): declare Browser Use build dependency

* fix(browser-use): preserve successful navigation and dialog input

* fix(browser-use): harden input drain and tab registration cleanup

* fix(browser-use): keep sessions bound to one Chrome profile

* fix(browser-use): bound commands and validate evaluation JSON

* fix(browser-use): reuse unchanged Native Host registration

* fix(browser-use): wait for fresh extension installation

* fix(browser-use): address review round on screenshot origin, dialog scope, and bridge privacy

- screenshot: tolerate settle-probe failure (measure the ratio from the
  captured pixels, re-capture on HiDPI), measure layout after the settle
  wait, and reject screencast frames whose scroll offsets disagree with
  the published origin
- session: clear the cached dialog only on main-frame navigation;
  abort tab attachment when the tab is removed mid-attach
- errors: classify crashes and timeouts by Playwright's structured error
  name instead of page-influenced message text
- locator.press: release chord modifiers when a later token is rejected;
  cap locator.type at the 60k characters its per-character budget can
  deliver inside the 120s ceiling
- snapshot: keep AI-mode [cursor=pointer] generic nodes in the
  interactive listing
- bridge: derive the fallback socket directory as an env-independent
  per-user 0700 subdirectory on all POSIX hosts and verify ownership
  before binding

* fix(browser-use): close review-critical gaps in error mapping, budgets, and downloads

- errors: classify by Playwright error name first; text phrases match only
  the start of the first message line so page-controlled text (log tails,
  quoted markup, page-thrown values) cannot pick the reported code, and a
  TimeoutError name wins over crash-shaped page text
- transport: cdp.send bridge requests default to 130s so operations up to
  the 120s schema ceiling report their own deadline; other requests keep
  the 30s default
- transport: the /tmp fallback socket directory is a per-user leaf directly
  under the sticky root (no shared intermediate a first mover could own);
  ensureSocketDirectory re-verifies the leaf after creation and walks
  ancestors up to the sticky root, requiring each to be owned by this user
  or root and not group/other-writable
- runtime: withModifiers no longer rewrites a completed action into a
  failure when a modifier release fails; chord failure cleanup releases the
  chord's own tokens (including non-modifiers) instead of four fixed names
- playwright: enable CDP download events on tab attach (Playwright's
  noDefaults connect leaves them off, so waitForEvent('download') could
  never resolve); locator.downloadMedia races the caller deadline, aborts
  the in-page fetch at it, resolves media contained in a located wrapper
  before an ancestor link, restricts URL schemes, and names CORS rejections
  distinctly; locator.allTextContents waits for the first match within the
  caller budget and still resolves [] when nothing attaches
- screenshot: full-page captures get a 16 Mi-pixel budget (1920x3000 pages
  now succeed) and the encoded byte ceiling drops to the 4 MiB model image
  limit so captures are not dropped at the model boundary
- snapshot: interactiveOnly promotes only a terminal [cursor=pointer]
  attribute (page text quoting the marker no longer promotes the line), and
  truncation skips over-long lines instead of discarding the rest
- schemas: reject empty string/regex matchers and exact:true combined with
  a regex matcher (Playwright silently drops exact for regexes)
- tests: the SDK evaluate fixture now mirrors production serialization;
  POSIX socket tests carry win32 gates; posix.join keeps the derived socket
  path a pure function of uid and platform on every host

* fix(browser-use): close review criticals in error mapping, chords, downloads, and snapshots

- errors: classify Playwright's genuine strict-mode violation, which
  crosses CDP as the raw exception description and reaches the client
  behind an "Error: " layer ("locator.click: Error: strict mode
  violation: ... resolved to N elements:"); requiring the element count
  keeps a page-thrown lookalike from forging LOCATOR_NOT_UNIQUE
- runtime-helpers: on a failed chord press, release the tokens
  Playwright actually derived ('+' separates only after a non-empty
  token, so a standalone '+' is a literal key) rather than the caller's
  array elements; an element embedding '+' previously released nothing
  and left modifiers held. locator.press uses the same tokenizer
- locator.downloadMedia: resolve an ordered candidate list (element,
  media descendants, anchor descendants, closest ancestor) and keep the
  first candidate that exposes a URL, so a wrapping anchor no longer
  beats the image it contains and a source srcset or an unloaded
  video's source child can supply the URL
- snapshot: unwrap Playwright's single-quoted YAML key before the
  [cursor=pointer] suffix test, so clickable nodes whose accessible
  name forces key quoting stay in the interactive listing
- docs: align the Input completion contract with the shipped modifier
  cleanup semantics (cleanup failures are discarded; only the action's
  own error propagates) in both languages

* fix(browser-use): close round-13 review criticals in errors, srcset, evaluateAll, snapshots, and screenshot origin

* fix(browser-use): point the pnpm importer at a vitest snapshot that exists

Merging main brought in #11625, which regenerated pnpm-lock.yaml and
dropped the vitest@3.2.7 snapshot keyed with @vitest/browser and
supports-color@8.1.1. The packages/browser-use importer still referenced
that key, so every Install leg failed with
ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY. Re-key the importer to the snapshot
its sibling packages already use; nothing else in the lockfile changes.

* fix(browser-use): close round-14 criticals in error classification, dialog ordering and screenshot ratio

R13-2: the strict-mode branch of sanitizeOperationError was the only
text-phrase branch not gated on the evaluate channel, so a page-thrown
primitive "strict mode violation: ... resolved to 2 elements:" picked
LOCATOR_NOT_UNIQUE. On that channel only Playwright's "Error: "-wrapped
form classifies now; other channels keep the unwrapped form.

R14-1: Playwright hands a dialog over on a later turn than the bridge
reports its CDP events (a microtask in CRSession plus the in-process
dispatcher's setImmediate), so an open+close pair delivered in one chunk
left a phantom dialog that blocked the tab. Each tab now traces dialog
openings and closes in bridge order and drops a delivered dialog the
bridge already reported closed; a close with no traced opening (a dialog
open before the tab was attached) is never charged to a later dialog, and
the microtask clear stays for the delivered case.

R14-2: window.devicePixelRatio is page script, and a spoofed value became
an unbounded clip.scale while the pixel re-measure only ran when the
probe failed. The probed ratio is now trusted only within 0.25..8, and the
measure-and-recapture correction runs whenever the first fallback capture
disagrees with the requested CSS region, deriving the real ratio from
Chrome's own pixels (measured / (width * scale)).

Also resolve the temp root in the ensureSocketDirectory tests: the
ancestor sweep rejects symlinks, and macOS reaches os.tmpdir() through
/var -> /private/var, so the tests failed locally on macOS.

* fix(browser-use): tear down accepted peers on start failure and pin the transport's untested paths

The esbuild banner declared require/__filename/__dirname/process bindings
the bundle never uses, so any bundled module declaring one of them built
green but failed to load; drop it (and the unused .wasm loader) and import
dist/index.js after bundling so an unloadable bundle fails the build.

When start() fails after listen (chmod, handshake), a peer validated in
between kept server.close() waiting forever; destroy and clear the
accepted sockets first, mirroring stop(). net.Server has no
closeAllConnections(), so that is the whole fix.

Tests now cover the QWEN_BROWSER_USE_SOCKET_PATH override, a hello with a
mismatched protocol version or extension id, onConnectionChange on the
real transport, an in-flight request when the extension disconnects and
the configured request budget, the ok:false error-code mapping,
FrameDecoder split-frame reassembly and its length ceiling, the
start-failure teardown, and all five schema refine predicates.

* fix(browser-use): mirror the contract bounds in the SDK and state the documented limits

nth() now rejects indexes outside the contract's -10000..10000 before
dispatch, and all() fails closed when the plan already has 32 steps or
the match count exceeds what nth() can address, instead of handing back
locators that only fail on first use. Both evaluate script builders
normalise method-shorthand and class-method sources the way Playwright
does (compile-only vm.Script probe, then a function prefix), so those
well-typed callbacks no longer die as a page-side SyntaxError.

providerTab clamps title and url to the claimTab schema's 20000-char cap
so the documented openTabs() -> claimTab() round trip survives long URLs,
and orderOpenTabs() establishes the http(s)-only, newest-first listing
the model documentation promises. downloadMedia derives the file name
from the URL path only for http(s) sources and leaves blob:/data: names
to the browser.

The model-facing documentation now states the 1s read default, that
dom_cua.scroll's x/y are wheel deltas rather than coordinates, and that
a cross-origin resource without CORS headers cannot be downloaded from
the page origin; the design docs record that boundary as follow-up work.
Tests pin every one of these, plus buildLocator's frame, combinator,
filter and positional branches, the file-chooser SDK surface, and the
claimTab argument guards.

* fix(browser-use): keep background tab registration from moving the selection

registerTab selected every tab it registered, and two of its callers are
background paths (derivedTabTracked, syncDerivedTabs via tabs.list), so
a read-only tabs.list could move tabs.selected onto a popup. Selection is
now set only by newTab and claimTab from the info they return.

Also: tabs.get is allowed while a dialog is open, matching tabs.list and
tabs.selected, which already return the dialog-safe TabInfo; the
auxiliary-button click moves Playwright's tracked cursor before sending
the CDP button events so a later wheel lands where the click did; the
dead providerTabId lookup is removed; openTabs applies orderOpenTabs.

Tests: the runtime test file mocks node:timers so vi.useFakeTimers()
actually controls the input drain bound (the 250ms test drops to ~0ms),
and new cases pin the drain clearTimeout, the registration probe's
session detach, promptText pass-through, tabs.get under a dialog, the
openTabs/claimTab fail-closed guards and ordering, popup adoption from
derivedTabTracked, the abandoned navigation waiter's swallowed rejection,
the uploadFiles path guards, and the screencast fallback and byte budget
on the unconstrained capture path.

* test(browser-use): pin the element bindings the runtime gives evaluate scripts

The SDK emits scripts that read the free variables `element` and
`elements`, and the runtime binds them when it compiles the script inside
the page; no test ran either page-side callback, so swapping the binding
name on one side kept the suite green while every locator.evaluate would
throw a ReferenceError in production. Run the real callbacks with fake
elements so the contract is pinned from both ends.

* fix(browser-use): fail closed on page-controlled strict-mode text and prefer contained media over anchor hrefs

* fix(browser-use): bound page title reads and download a located anchor's file over its icon

* fix(browser-use): correct media downloads and frame composition

* fix(browser-use): bound locator.count and allTextContents by the caller deadline

* fix(browser-use): preserve native snapshots for DOM CUA

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(browser-use): classify a closed tab from the tab's own state

The pinned playwright-core client renders a closed target as a plain Error:
only TimeoutError sets `name`, so the `name === 'TargetClosedError'` branch
in sanitizeOperationError never fired, and because evaluate-channel text
fails closed by design, a tab that closed or crashed while a page command
ran was reported as OPERATION_FAILED instead of the documented STALE_TAB.

Decide tab-gone in the dispatcher from `tab.stale` and `page.isClosed()`,
which the page observers keep current, before the text classifier runs.
Drop the dead name branch and replace the test that fabricated the name
with one built the way the client builds the error, plus dispatch-level
cases for both signals that fail without the dispatcher check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(browser-use): restore the kernel realm process binding and land the round-5 review batch

Criticals from review round 5, each reproduced before fixing:

- The bundled skill imports the staged runtime inside the node_repl kernel,
  whose untrusted vm context has no `process` global and denies the
  `node:process` specifier. 3fc2dc3e10 removed the bundle banner that bound
  `process` through createRequire, so the skill's first cell threw
  `ReferenceError: process is not defined`; the build's own load check runs
  in the main realm and could not see it. Restore the binding on the index
  build and add a kernel-backed test that runs SKILL.md's first cell through
  the real kernel and fails on the ReferenceError (R5-4).
- Bump @qwen-code/node-repl-mcp to 0.1.6: this PR changes its published
  source (image metadata channel) and the package is excluded from the
  repo-wide bump, so the release lane's integrity check would fail. Pin the
  browser-use skill to that version, have the skill test assert the pin
  equals the package version, and extend the cua-driver release gate to
  cross-check the browser-use pin (R5-2, R3-5).
- Report an incompatible extension as EXTENSION_VERSION_MISMATCH so
  browser discovery surfaces the update guidance instead of an empty list
  after the connect budget (R5-5).
- Make tab grouping non-fatal in tabs.create, matching its siblings, so a
  cosmetic grouping failure no longer closes the tab just created (R5-6).

Review suggestions verified still applying, with tests proven red under
mutation: typecheck the scripts directory; pin the Native Messaging chunk
boundary and envelope; return installedPaths from the installer; treat a
blank install-home override as unset; warn on a skipped foreign manifest;
refuse a foreign launcher with the refusals' missing red arms; keep the
preflight download waiter handled; settle the parked-profile test; name
the private-directory cause in the bridge start error; declare the finalize
status union once; reject unknown or closed kept tabs; pin the extension's
protocol constants to the SDK source; resolve the extension test root from
the module; drop the duplicated permission assertion; make the group
barrier real; read the overlay style rules; map a vanished tab to STALE_TAB
in the relay; clamp listed tab text to the schema bound; remove the
unreachable onDetach child-session branch; assert the raced tab in the
disconnect test; cover the history scheme filter and session-name reset;
and fix the extension-source and prerequisite wording in the docs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(browser-use): let the SauceDemo smoke recognize equivalent skill usage

The model-driven smoke completed the task correctly on every run but its
transcript checks asserted one exact spelling: the literal runtime entry
path in the model's code, and one parent-scoped price selector with an
exact number of dollar values in the cell's output. A model that builds
the entry from the skill root across cells, scopes the price locator
differently, or echoes its chosen items after the read failed the smoke.

Key the import check on what every spelling shares: the built-in skill
root the CLI substitutes into SKILL.md, the entry file name and the setup
call. Match price reads on the price class, prefer a read that printed
exactly the expected count, take the leading set otherwise, and take the
last such read for the cart since the flow reads it after the inventory.
The smoke-checks test covers all three import spellings, a foreign runtime
and a missing setup call, and the parser's scoped, echoed and non-list
reads. Verified against a real Chromium 151 run: all six checks true.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(browser-use): close six fail-open paths in the playwright adapter

Round-22 review found six Critical defects in the new package; each is
fixed at its root and pinned by a test that goes red without the fix:

- locator.evaluateAll opened a second full timeout window after the
  attach wait had consumed the caller's budget; the wait and the read
  now share one deadline, and a wait that consumed the budget settles
  [] without evaluating.
- tab.goto passed any string to page.goto, so a file:// URL would have
  handed local file contents to the tab's evaluate and snapshot reads;
  goto is gated to http(s) before the navigation runs.
- Synthesized CDP error responses carried no code, so a reply orphaned
  by a target crash tripped Playwright's internal assert and tore down
  the whole transport; they now carry -32001, the orphan-swallow code.
- A dialog already open when a tab attaches is replayed by neither
  Chrome nor Playwright, so the DIALOG_OPEN gate stayed blind while the
  modal blocked the renderer; registration now probes the renderer and
  gates the tab on an unanswered probe, with getJsDialog reporting it
  under a stable handle whose accept/dismiss run over CDP.
- A dialog opening Playwright never delivered stayed in the trace
  forever and swallowed the charge of every later dialog on the tab; a
  closed, undelivered entry is retired once the dispatcher turn has
  passed.
- snapshotRefLocator validated only the ref's shape, so a ref from a
  pre-navigation snapshot could act on whichever element the new
  document happened to hand the same string; refs are bound to the
  snapshot that emitted them and cleared on main-frame navigation.

Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>

* build: exempt the staged Browser Use skill runtime from the dependency-direction check

The browser-use package build stages the bundled skill's runtime, node_modules
included, under packages/core/src/skills/bundled/browser-use/runtime (git-ignored),
and the TUI dependency-direction gate fails on any node_modules inside a protected
root. Declare that staged, ignored root as a build artifact so the gate skips it
without a diagnostic; every other skipped-directory name still fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWe8PKNMpsKdQruTWkvFFc

* build: leave the staged Browser Use runtime out of the dependency-direction walk entirely

The staged root is a build artifact, not source: skipping only its
node_modules while still parsing the SDK bundle next to it was inconsistent.
Do not walk anything under a declared staged root; every other node_modules
inside the protected roots remains a diagnostic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWe8PKNMpsKdQruTWkvFFc

* build(browser-use): stage the skill runtime only for the bundle and for dev mode

The package build used to copy the bundled skill runtime, node_modules
included, into packages/core/src, which every built tree then carried and
which the TUI dependency-direction gate rightly rejects. Follow the qc-helper
convention instead: the bundle step stages the runtime into dist/bundled for
the shipped CLI, and `npm run dev` stages the git-ignored development copy
next to the source SKILL.md, because only dev mode reads bundled skills from
the source tree. No build step writes into src any more, so the gate
exemption added earlier is dropped and the check script returns to main's
version. The kernel-realm test stages its own copy into a temporary skill
directory rather than depending on a build side effect.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWe8PKNMpsKdQruTWkvFFc

* fix(browser-use): deliver bridge messages in arrival order and keep dev info flags fast

Three fixes from the round-3/4 verification and review round 7–8:

- Transport: a response only settles a promise, so its consumer ran on the
  microtask queue while events from the same socket chunk were emitted
  synchronously and overtook it. Playwright installs a page's renderer and
  lifecycle listeners inside `Page.getFrameTree().then(...)`, so an
  overtaking `Runtime.executionContextCreated` found no listener and a
  claimed tab lost its main world for good (B1). Inbound messages now go
  through one per-socket queue whose drain yields a macrotask after
  settling a response, so every continuation runs before the next message;
  chunks arriving meanwhile join the same queue. Regression:
  `delivers a response ahead of the events that share its socket chunk`.
- Relay: `requireControllableTab` judged a popup by `tab.url`, which is
  empty until the navigation commits, while the claim keyed on
  `pendingUrl`; the runtime's immediate `tabs.get` therefore rejected the
  tab the relay had just reported (R8-2). It now judges by
  `pendingUrl || url`, like the claim. Regression:
  `a popup known only by its pending URL stays controllable`.
- dev launcher: the dev-mode runtime staging skips `--version`/`--help`
  (and `-v`/`-h`), restoring the fast path the existing tests pin (R7-1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWe8PKNMpsKdQruTWkvFFc

* test(browser-use): make the smoke evidence and the persist-before-connect pin honest

Round 6–7 review findings on the verification tooling:

- The SauceDemo smoke decided "the staged runtime was loaded" from three
  substring tests over all cells joined, so a run whose staged import threw
  and fell back to a foreign entry still passed (R6-1). It now looks at the
  import expressions per cell: a literal path under the staged runtime, or a
  path assembled from the substituted skill root, in a cell that reaches the
  setup export; a literal entry loaded from anywhere else disqualifies the
  run.
- Price reads were attributed to the cart by count and read order, so an
  inventory sorted low-to-high passed as a cart read and the fallback to the
  first read never returned null (R6-2). Reads are now attributed by the
  tab.url() confirmation of cart.html the prompt requires: inventory before
  it, cart after it, and no cart read without it.
- The persist-before-connect invariant was asserted inside the mock's
  postMessage, which the bridge calls under a bare catch, so the reordered
  mutant stayed green (R7-2). The mock now records every hello with the
  identity persisted at that moment and the test asserts the order outside
  the bridge; the mutant is red.
- SKILL.md states the node-repl-mcp 0.1.6 requirement next to the
  emitImage step the agent always reads, with how to detect and re-register
  an older server (R6-3).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MWe8PKNMpsKdQruTWkvFFc

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 05:50:24 +00:00

741 lines
27 KiB
JavaScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import {
dirname,
join,
basename,
resolve,
relative,
sep,
extname,
} from 'node:path';
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { glob } from 'glob';
import fs from 'node:fs';
import { copyBrowserUseAssets } from './copy-browser-use-assets.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const defaultRoot = join(__dirname, '..');
// Exported for `scripts/tests/review-source-digest.test.ts`, which holds the
// skill root's digest allowlist up to everything this rule lets the copier
// ship — a file the copier would carry but the digest cannot see is a
// staleness check with a blind spot.
export const BUNDLED_SKILL_TEST_FILE_RE =
/\.(?:test|spec)\.(?:d\.)?[cm]?[jt]sx?(?:\.map)?$/;
/**
* The digest of every review source this bundle was built from.
*
* A staleness hint, not an integrity control: an unsigned file beside the
* bundle, which anyone who can write `dist/` can write.
*
* Kept in step with `stale-bundle.ts`, which re-derives it the same way — and
* duplicated rather than shared, because this script runs before the package
* it would import has been built. `scripts/tests/review-source-digest.test.ts`
* is what holds the two equal; nothing here is imported from there.
*
* Tests and fixtures are excluded on both sides: esbuild follows imports from
* the CLI entry, neither is reachable that way, and a warning fired by an
* edit to a file the bundle cannot contain is the false positive this check
* exists not to produce. DESIGN.md is excluded for the same reason: the
* copier below deliberately does not ship it.
*/
// Mirrors NOT_BUNDLED_RE / NOT_BUNDLED_DIR / NOT_BUNDLED_FILE /
// NOT_BUNDLED_SKILL_FILE / DIGESTED_EXTENSIONS in stale-bundle.ts; the
// parity test keeps them equal.
const NOT_BUNDLED_RE = /\.(?:test|spec)\.(?:d\.)?[cm]?[jt]sx?$/;
const NOT_BUNDLED_DIR = new Set(['__fixtures__', '__snapshots__']);
const NOT_BUNDLED_FILE = new Set(['test-utils.ts']);
const NOT_BUNDLED_SKILL_FILE = new Set(['DESIGN.md']);
const DIGESTED_EXTENSIONS = {
code: new Set([
'.ts',
'.tsx',
'.mts',
'.cts',
'.js',
'.mjs',
'.json',
'.jsx',
]),
skill: new Set(['.md']),
};
function isDigestedFile(kind, name) {
if (!DIGESTED_EXTENSIONS[kind].has(extname(name))) return false;
if (kind !== 'code') return !NOT_BUNDLED_SKILL_FILE.has(name);
return !NOT_BUNDLED_RE.test(name) && !NOT_BUNDLED_FILE.has(name);
}
export function reviewSourceDigestForBuild(root) {
const cliCommands = join(root, 'packages', 'cli', 'src', 'commands');
const roots = [
{ path: join(cliCommands, 'review'), kind: 'code' },
{ path: join(cliCommands, 'review.ts'), kind: 'code' },
// Mirrors the lease root `reviewSourceRoots` (stale-bundle.ts) adds; the
// repo-tree case in review-source-digest.test.ts holds the two equal.
{
path: join(
root,
'packages',
'cli',
'src',
'services',
'review-worktree-lease.ts',
),
kind: 'code',
},
// Mirrors the utils-helpers roots `reviewSourceRoots` (stale-bundle.ts)
// adds; the repo-tree case in review-source-digest.test.ts holds the two
// equal.
{
path: join(root, 'packages', 'cli', 'src', 'utils', 'shell-args.ts'),
kind: 'code',
},
{
path: join(root, 'packages', 'cli', 'src', 'utils', 'paths.ts'),
kind: 'code',
},
{
path: join(
root,
'packages',
'core',
'src',
'skills',
'bundled',
'review',
),
kind: 'skill',
},
];
const files = [];
// `listed` is true when the parent walk saw this path as a directory a
// moment ago: if it cannot be listed now, the tree changed underneath the
// walk and the measurement is incomplete, where a root that was never there
// has simply nothing to say.
const walk = (dir, kind, listed) => {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
// `readdirSync` failing says only "this path did not list" — the reason
// decides everything, and `statSync` states it directly, where inferring
// "this is a file" from `ENOTDIR` assumes every platform's libuv maps
// the case the same way, and a one-sided divergence would drop
// `review.ts` from one digest and not the other — a bundle that is
// byte-for-byte correct warning on every review, forever, on that
// platform alone. A directory that could not be listed (EACCES, or
// vanished after the parent listed it) throws instead of being skipped:
// a digest over the survivors would be stamped as the truth, and every
// review from then on would report stale against it.
let stats;
try {
stats = fs.statSync(dir);
} catch (error) {
if (listed) {
throw new Error(`${dir} vanished while being walked`);
}
// A root that was never there is silent only on ENOENT — EACCES/EPERM
// is a tree that IS there but cannot be measured, and skipping it
// would stamp a digest over the surviving roots, which the runtime
// twin (`sourceFilesUnder` in stale-bundle.ts) marks incomplete: a
// parity gap that would accuse a byte-for-byte correct bundle once
// the tree becomes readable again.
if (error?.code !== 'ENOENT') {
throw new Error(`${dir} could not be measured`);
}
return;
}
if (stats.isDirectory()) {
throw new Error(`${dir} could not be listed`);
}
if (stats.isFile() && isDigestedFile(kind, basename(dir))) {
files.push(dir);
}
return;
}
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
if (kind !== 'code' || !NOT_BUNDLED_DIR.has(e.name))
walk(full, kind, true);
} else if (e.isFile() && isDigestedFile(kind, e.name)) {
files.push(full);
}
}
};
for (const r of roots) walk(r.path, r.kind, false);
if (files.length === 0)
return { digest: undefined, count: 0, newest: undefined };
const hash = createHash('sha256');
let newest;
for (const file of files.sort()) {
const { mtimeMs } = fs.statSync(file);
if (!newest || mtimeMs > newest.mtimeMs) newest = { file, mtimeMs };
hash.update(relative(root, file).split(sep).join('/'));
hash.update('\0');
hash.update(fs.readFileSync(file));
hash.update('\0');
}
return { digest: hash.digest('hex'), count: files.length, newest };
}
function stampReviewSourceDigest(root, distDir) {
const stampPath = join(distDir, 'review-sources.sha256');
// Every refusal below removes an existing stamp first. Leaving an older
// stamp beside a newer bundle is a weaker form of the misdescription the
// refusal exists to avoid, and `unmeasured` is the state each refusal means.
const refuse = (why) => {
try {
fs.rmSync(stampPath, { recursive: true, force: true });
} catch (error) {
// `force` swallows only ENOENT; a permission or lock error on the old
// stamp must not kill the bundle step after every asset is in place.
// Leaving it is the lesser outcome: at worst a later "stale" notice
// whose rebuild advice is still correct.
console.warn(
`Could not remove the stale source digest at ${stampPath}: ` +
(error instanceof Error ? error.message : error),
);
}
// `warn`, not `log`: the runtime message sends its reader back to this
// build's output, and a refusal hidden among plain logs is not what they
// are being sent to find.
console.warn(why);
};
// Never fatal. This is the last step of the copier, so a file vanishing
// mid-walk would fail the bundle after every asset was already in place —
// and a missing stamp is only `unmeasured`, which the runtime check already
// treats as an acceptable answer.
let digest, count, newest;
try {
({ digest, count, newest } = reviewSourceDigestForBuild(root));
} catch (error) {
refuse(
`Could not read the review sources; skipped the source digest: ${
error instanceof Error ? error.message : error
}`,
);
return;
}
if (!digest) {
refuse('No review sources found; skipped the source digest.');
return;
}
// The digest describes the tree as the COPIER sees it, and the copier runs
// after esbuild — so a source edited after the bundle was written, or this
// script run on its own, would stamp a `cli.js` built from something else.
// That is the only direction where silence is affirmatively wrong instead
// of merely uninformative: every other gap here degrades to `unmeasured`.
// Timestamps are the wrong tool for judging staleness and the right one for
// judging whether this stamp can be honest at all, so refuse rather than
// stamp. The anchor is the bundle's WRITE time, not the build's start:
// esbuild reads the sources during the graph walk, before it writes, so an
// edit landing while the build is in flight is older than this anchor,
// passes the gate, and is what the copier digests — a stamp honest about
// the tree and not about the bundle. That window is not covered here.
const bundlePath = join(distDir, 'cli.js');
let builtAt;
try {
builtAt = fs.statSync(bundlePath).mtimeMs;
} catch {
refuse('No bundle found to stamp; skipped the source digest.');
return;
}
const newestSource = newest.mtimeMs;
if (newestSource > builtAt) {
refuse(
`A review source is newer than ${bundlePath}; skipped the source digest ` +
`rather than stamp a bundle it may not describe. Run \`npm run bundle\` ` +
`to rebuild the bundle and the stamp together.`,
);
return;
}
try {
fs.writeFileSync(stampPath, digest);
} catch (error) {
refuse(
`Could not write the source digest; skipped the stamp: ${
error instanceof Error ? error.message : error
}`,
);
return;
}
console.log(`Stamped the review source digest over ${count} files.`);
}
/**
* Whether this host's Node.js links against musl rather than glibc. The
* report header only carries `glibcVersionRuntime` on a glibc runtime.
*/
function hostUsesMusl() {
if (process.platform !== 'linux') return false;
try {
return !process.report?.getReport?.()?.header?.glibcVersionRuntime;
} catch {
return false;
}
}
/**
* Whether an `@opentui/core-*` package holds a native library this host
* cannot load: the Linux packages ship one library per libc, and npm
* installs both flavors on a glibc host because only `libc` (not `os`/`cpu`)
* separates them.
*
* Shipping the foreign one is not merely dead weight. Both are ELF files, so
* AppImage packaging walks them with `ldd` while deploying dependencies; the
* musl library needs `libc.so`, which no glibc system resolves, so `ldd`
* exits non-zero and aborts linuxdeploy. That is what broke the Linux job of
* desktop release 0.2.3-preview.0, which bundles this dist tree verbatim.
*/
function isForeignLibcPackage(entry, musl = hostUsesMusl()) {
if (!entry.startsWith('core-linux-')) return false;
return entry.endsWith('-musl') !== musl;
}
/**
* OpenTUI renderer runtime assets (tree-sitter grammars, the parser worker,
* `web-tree-sitter` runtime and the native render library) relocated next to
* the bundle.
*
* In the esbuild single-file bundle @opentui/core's package-relative asset
* fallback (`new URL('./assets/…', import.meta.url)`) points into `dist/`
* and misses, so code-block syntax highlighting degrades to single color.
* The runtime (`packages/cli/src/ui/opentui/opentui-assets.ts`) therefore
* points `OTUI_ASSET_ROOT` at `dist/opentui-assets/` — but only when the tree
* there is complete, because @opentui/core throws on any missing key under a
* configured root (including the native library). This copier writes the
* assets under exactly the keys that module checks:
*
* opentui-assets/@opentui/core/parser.worker.js
* opentui-assets/@opentui/core/assets/<lang>/<file>
* opentui-assets/@opentui/core-<platform>-<arch>[ -musl]/libopentui.*
* opentui-assets/web-tree-sitter/tree-sitter.wasm
*
* Returns the list of destination-relative keys copied (empty on skip) so
* callers/tests can assert on it. Never fails the bundle: a missing
* @opentui/core (e.g. an install without the renderer) only warns.
*/
export function copyOpenTuiAssets({
root = defaultRoot,
distDir,
musl = hostUsesMusl(),
} = {}) {
distDir ??= join(root, 'dist');
const destRoot = join(distDir, 'opentui-assets');
// Start from a clean tree: the runtime gate below checks key existence
// only, so a tree left by an earlier bundle would still satisfy it and
// ship stale native libraries after the installed platform set changed.
fs.rmSync(destRoot, { recursive: true, force: true });
// @opentui/core may sit in the root node_modules or be hoisted into
// packages/cli/node_modules (it is a cli dependency); try both anchors.
const anchors = [
join(root, 'package.json'),
join(root, 'packages', 'cli', 'package.json'),
];
let coreEntry;
for (const anchor of anchors) {
if (!existsSync(anchor)) continue;
try {
coreEntry = createRequire(anchor).resolve('@opentui/core');
break;
} catch {
// try the next anchor
}
}
if (!coreEntry) {
console.warn(
'Warning: @opentui/core is not resolvable; skipped OpenTUI assets',
);
return [];
}
const coreDir = dirname(coreEntry);
const coreRequire = createRequire(join(coreDir, 'package.json'));
const copied = [];
const copyToKey = (source, key) => {
if (!existsSync(source) || !statSync(source).isFile()) {
console.warn(`Warning: OpenTUI asset missing at ${source}; skipped`);
return;
}
const destPath = join(destRoot, key);
mkdirSync(dirname(destPath), { recursive: true });
copyFileSync(source, destPath);
copied.push(key);
};
// 1. The tree-sitter parser worker (self-contained bundle).
copyToKey(
join(coreDir, 'parser.worker.js'),
'@opentui/core/parser.worker.js',
);
// 2. Grammar assets: assets/<lang>/{highlights.scm,injections.scm,*.wasm}.
const assetsDir = join(coreDir, 'assets');
if (existsSync(assetsDir)) {
for (const lang of fs.readdirSync(assetsDir)) {
const langDir = join(assetsDir, lang);
if (!statSync(langDir).isDirectory()) continue;
for (const file of fs.readdirSync(langDir)) {
copyToKey(join(langDir, file), `@opentui/core/assets/${lang}/${file}`);
}
}
} else {
console.warn(`Warning: OpenTUI grammar assets not found at ${assetsDir}`);
}
// 3. The web-tree-sitter runtime wasm (peer dependency of @opentui/core).
let wasmSource;
try {
wasmSource = coreRequire.resolve('web-tree-sitter/tree-sitter.wasm');
} catch {
try {
wasmSource = join(
dirname(coreRequire.resolve('web-tree-sitter')),
'tree-sitter.wasm',
);
} catch {
wasmSource = undefined;
}
}
if (wasmSource) {
copyToKey(wasmSource, 'web-tree-sitter/tree-sitter.wasm');
} else {
console.warn(
'Warning: web-tree-sitter is not resolvable; skipped its wasm asset',
);
}
// 4. Native render libraries for every installed @opentui platform package
// (core-darwin-arm64, core-win32-x64, …), minus the Linux packages built
// for the other libc (see isForeignLibcPackage). The runtime only needs
// the current platform's, but shipping the rest of the installed set
// keeps the completeness check satisfiable wherever the bundle runs
// next. The platform packages live beside @opentui/core or hoisted at
// the repo root; scan both scope directories.
const scopeDirs = [dirname(coreDir), join(root, 'node_modules', '@opentui')];
const seenPackages = new Set();
for (const scopeDir of scopeDirs) {
if (!existsSync(scopeDir)) continue;
for (const entry of fs.readdirSync(scopeDir)) {
if (!entry.startsWith('core-') || seenPackages.has(entry)) continue;
if (isForeignLibcPackage(entry, musl)) continue;
const packageDir = join(scopeDir, entry);
if (!statSync(packageDir).isDirectory()) continue;
let copiedFromPackage = false;
for (const file of fs.readdirSync(packageDir)) {
if (!/\.(dylib|so|dll)$/.test(file)) continue;
copyToKey(join(packageDir, file), `@opentui/${entry}/${file}`);
copiedFromPackage = true;
}
if (copiedFromPackage) seenPackages.add(entry);
}
}
if (copied.length > 0) {
console.log(
`Copied ${copied.length} OpenTUI runtime assets to dist/opentui-assets/`,
);
} else {
console.warn('Warning: no OpenTUI runtime assets were copied');
}
return copied;
}
export function copyBundleAssets({ root = defaultRoot } = {}) {
const distDir = join(root, 'dist');
const coreVendorDir = join(root, 'packages', 'core', 'vendor');
// Create the dist directory if it doesn't exist
if (!existsSync(distDir)) {
mkdirSync(distDir);
}
// Find and copy all .sb files from packages to the root of the dist directory
const sbFiles = glob.sync('packages/**/*.sb', { cwd: root });
for (const file of sbFiles) {
copyFileSync(join(root, file), join(distDir, basename(file)));
}
console.log('Copied sandbox profiles to dist/');
// Copy vendor directory (contains ripgrep binaries)
console.log('Copying vendor directory...');
if (existsSync(coreVendorDir)) {
const destVendorDir = join(distDir, 'vendor');
copyRecursiveSync(coreVendorDir, destVendorDir);
console.log('Copied vendor directory to dist/');
} else {
console.warn(`Warning: Vendor directory not found at ${coreVendorDir}`);
}
// Copy bundled skills (e.g. /review) so they are available at runtime.
// In the esbuild bundle, import.meta.url resolves to dist/cli.js, so
// SkillManager looks for bundled skills at dist/bundled/.
const bundledSkillsDir = join(
root,
'packages',
'core',
'src',
'skills',
'bundled',
);
if (existsSync(bundledSkillsDir)) {
const destBundledDir = join(distDir, 'bundled');
fs.rmSync(destBundledDir, { recursive: true, force: true });
copyRecursiveSync(bundledSkillsDir, destBundledDir, {
// DESIGN.md files are maintainer design narratives, not runtime inputs;
// shipping one would hand a review a ~125 KB read_file target that
// outweighs the context the slimmed skill saves.
skipEntry: (entry, sourcePath) =>
isBundledSkillTestFile(entry) ||
entry === 'DESIGN.md' ||
sourcePath === join(bundledSkillsDir, 'browser-use', 'runtime'),
});
if (existsSync(join(bundledSkillsDir, 'browser-use', 'SKILL.md'))) {
copyBrowserUseAssets(root, join(destBundledDir, 'browser-use'));
}
console.log('Copied bundled skills to dist/bundled/');
} else {
console.warn(
`Warning: Bundled skills directory not found at ${bundledSkillsDir}`,
);
}
// Copy user docs into qc-helper bundled skill so it can reference them at runtime.
// The qc-helper skill reads docs from a `docs/` subdirectory relative to its own
// directory. In the esbuild bundle this becomes dist/bundled/qc-helper/docs/.
const userDocsDir = join(root, 'docs', 'users');
if (existsSync(userDocsDir)) {
const destDocsDir = join(distDir, 'bundled', 'qc-helper', 'docs');
copyRecursiveSync(userDocsDir, destDocsDir);
console.log('Copied docs/users/ to dist/bundled/qc-helper/docs/');
} else {
console.warn(`Warning: User docs directory not found at ${userDocsDir}`);
}
// Copy builtin locales so bundled dist/cli.js can load UI translations at runtime.
// Published packages already include these via prepare-package.js; bundle output
// should mirror that behavior for local `node dist/cli.js` runs.
const localesDir = join(root, 'packages', 'cli', 'src', 'i18n', 'locales');
if (existsSync(localesDir)) {
const destLocalesDir = join(distDir, 'locales');
copyRecursiveSync(localesDir, destLocalesDir);
console.log('Copied builtin locales to dist/locales/');
} else {
console.warn(`Warning: Locales directory not found at ${localesDir}`);
}
// Copy the OpenTUI renderer's runtime assets (tree-sitter grammars, parser
// worker, web-tree-sitter wasm, native render library) so the bundled
// renderer can point OTUI_ASSET_ROOT at dist/opentui-assets/ and keep
// code-block syntax highlighting. Warn-and-skip when unavailable.
copyOpenTuiAssets({ root, distDir });
// Copy extension templates so bundled dist/cli.js can scaffold
// `/extensions new` from the runtime examples directory.
const extensionExamplesDir = join(
root,
'packages',
'cli',
'src',
'commands',
'extensions',
'examples',
);
if (existsSync(extensionExamplesDir)) {
const destExtensionExamplesDir = join(distDir, 'examples');
copyRecursiveSync(extensionExamplesDir, destExtensionExamplesDir);
console.log('Copied extension examples to dist/examples/');
} else {
console.warn(
`Warning: Extension examples directory not found at ${extensionExamplesDir}`,
);
}
// Copy the built Web Shell SPA (index.html + assets/) so the bundled
// `qwen serve` can serve the browser UI at its root path. The library
// build outputs (dist/index.js, dist/types) are for npm consumers and are
// intentionally NOT copied. Source only exists after the web-shell
// workspace is built (npm run build); when absent (e.g. a --cli-only
// build, or bundling without a prior full build) we warn and skip so the
// bundle step never fails — the daemon then runs API-only at runtime.
const webShellDistDir = join(root, 'packages', 'web-shell', 'dist');
const webShellIndexHtml = join(webShellDistDir, 'index.html');
const webShellAssetsDir = join(webShellDistDir, 'assets');
if (existsSync(webShellIndexHtml) && existsSync(webShellAssetsDir)) {
const destWebShellDir = join(distDir, 'web-shell');
mkdirSync(destWebShellDir, { recursive: true });
copyFileSync(webShellIndexHtml, join(destWebShellDir, 'index.html'));
copyRecursiveSync(webShellAssetsDir, join(destWebShellDir, 'assets'));
for (const file of ['manifest.webmanifest', 'sw.js']) {
const source = join(webShellDistDir, file);
if (existsSync(source)) {
copyFileSync(source, join(destWebShellDir, file));
} else {
console.warn(`Warning: Web Shell PWA asset not found: ${source}`);
}
}
console.log('Copied Web Shell UI to dist/web-shell/');
} else {
console.warn(
`Warning: Web Shell assets not found at ${webShellDistDir}; ` +
'dist/web-shell/ will be absent and `qwen serve` runs API-only. ' +
'Run a full `npm run build` before bundling to include the UI.',
);
}
const exportTranscriptRenderer = join(
root,
'packages',
'web-templates',
'src',
'export-html',
'dist',
'export-transcript-document.js',
);
const exportTranscriptCss = join(
root,
'packages',
'web-templates',
'src',
'export-html',
'dist',
'export-transcript-document.css',
);
if (existsSync(exportTranscriptRenderer) && existsSync(exportTranscriptCss)) {
copyFileSync(
exportTranscriptRenderer,
join(distDir, 'export-transcript-document.js'),
);
copyFileSync(
exportTranscriptCss,
join(distDir, 'export-transcript-document.css'),
);
console.log('Copied HTML export renderer to dist/');
} else {
const missingExportTranscriptAssets = [
exportTranscriptRenderer,
exportTranscriptCss,
].filter((assetPath) => !existsSync(assetPath));
console.warn(
`Warning: HTML export renderer assets not found at ${missingExportTranscriptAssets.join(', ')}; ` +
'dist/ will carry no HTML export renderer. ' +
'Run a full `npm run build` before bundling to include it.',
);
}
// Stamp what the review sources looked like at build time. `/review` drives
// the bundle, not the working tree, so a review command edited after this
// point takes no effect — and without a record of what was built, the run
// cannot tell and neither can its reader. Compared, not trusted: the check
// reads this and re-derives the digest from the tree.
stampReviewSourceDigest(root, distDir);
// Make dist/cli.js directly executable: shellContextEnv blanks a
// QWEN_CODE_CLI whose entry a POSIX shell cannot exec (no shebang, or no
// execute bit), and the `"${QWEN_CODE_CLI:-qwen}"` fallback then silently
// runs whatever `qwen` the PATH resolves — a different install for every
// `review` subcommand of a session launched from this bundle. Measured on
// three live `review run`s: every agent-issued subcommand ran the machine's
// global install instead of the freshly bundled tree.
const cliEntry = join(distDir, 'cli.js');
if (existsSync(cliEntry)) {
const source = readFileSync(cliEntry, 'utf8');
if (!source.startsWith('#!')) {
// Preserve the bundle's write time across the rewrite. The digest stamp
// above reads this mtime as "when the bundle was built", and a bumped
// one certifies a bundle as newer than review sources edited before it
// — the staleness warning the skill's Step 0 stops on then never fires.
// (Only a standalone run of this script is exposed, since a full bundle
// stamps before reaching here, but that is a flow the gate contemplates.)
const { atime, mtime } = statSync(cliEntry);
writeFileSync(cliEntry, `#!/usr/bin/env node\n${source}`);
fs.utimesSync(cliEntry, atime, mtime);
}
// chmod does not touch mtime, so it stays outside the guard: a bundle
// that already carries a shebang may still arrive without the exec bit.
fs.chmodSync(cliEntry, 0o755);
}
console.log('\n✅ All bundle assets copied to dist/');
}
if (isDirectRun()) {
copyBundleAssets();
}
function isDirectRun() {
return process.argv[1]
? fileURLToPath(import.meta.url) === resolve(process.argv[1])
: false;
}
/**
* Recursively copy directory
*/
function copyRecursiveSync(src, dest, options = {}) {
if (!existsSync(src)) {
return;
}
const stats = statSync(src);
if (stats.isDirectory()) {
if (!existsSync(dest)) {
mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src);
for (const entry of entries) {
const srcPath = join(src, entry);
if (entry === '.DS_Store' || options.skipEntry?.(entry, srcPath)) {
continue;
}
const destPath = join(dest, entry);
copyRecursiveSync(srcPath, destPath, options);
}
} else {
copyFileSync(src, dest);
// Preserve execute permissions for binaries
const srcStats = statSync(src);
if (srcStats.mode & 0o111) {
fs.chmodSync(dest, srcStats.mode);
}
}
}
function isBundledSkillTestFile(fileName) {
return BUNDLED_SKILL_TEST_FILE_RE.test(fileName);
}