From ea2c6a63c954d32abb37eddf4a819f65a56a26ed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 3 Aug 2026 04:40:30 -0700 Subject: [PATCH] refactor(scripts): adopt shared scaffolding (#118514) * refactor(scripts): adopt shared scaffolding * fix(scripts): satisfy strict tooling checks * fix(scripts): preserve scaffolding contracts --- scripts/android-release-signing.mjs | 78 +++++----- scripts/audit-seams.mjs | 4 +- scripts/bundled-plugin-assets.mjs | 6 +- scripts/check-channel-agnostic-boundaries.mjs | 2 +- scripts/check-cli-startup-memory.mjs | 6 +- .../check-control-ui-precompressed-assets.mjs | 5 +- .../check-database-first-legacy-stores.mjs | 3 +- scripts/check-deprecated-jsdoc.mjs | 4 +- scripts/check-docker-e2e-boundaries.mjs | 5 +- scripts/check-duplicates.mjs | 5 +- scripts/check-dynamic-import-warts.mjs | 8 +- .../check-extension-package-tsc-boundary.mjs | 3 +- .../check-extension-plugin-sdk-boundary.mjs | 3 +- .../check-extension-wildcard-reexports.mjs | 4 +- scripts/check-kysely-guardrails.mjs | 2 +- ...check-plugin-extension-import-boundary.mjs | 3 +- scripts/check-plugin-sdk-subpath-exports.mjs | 5 +- .../check-plugin-sdk-wildcard-reexports.mjs | 4 +- scripts/check-protocol-registry.mjs | 6 +- scripts/check-protocol-since.mjs | 4 +- scripts/check-runtime-sidecar-loaders.mjs | 2 +- scripts/check-session-accessor-boundary.mjs | 2 +- ...eck-session-transcript-reader-boundary.mjs | 2 +- scripts/check-sqlite-transaction-boundary.mjs | 2 +- .../check-telegram-grammy-types-imports.mjs | 4 +- scripts/check-tsgo-core-boundary.mjs | 5 +- .../check-web-fetch-provider-boundaries.mjs | 6 +- .../check-web-search-provider-boundaries.mjs | 5 +- scripts/check.mjs | 38 +++-- scripts/docs-sync-publish.mjs | 6 +- scripts/e2e-sandbox-bind-conflict.mjs | 5 +- scripts/ensure-cli-startup-build.mjs | 6 +- scripts/ensure-extension-memory-build.mjs | 6 +- scripts/ensure-playwright-chromium.mjs | 5 +- scripts/format-docs.mjs | 4 +- scripts/full-release-validation-at-sha.d.mts | 7 - scripts/full-release-validation-at-sha.mjs | 51 ++++--- .../generate-dependency-release-evidence.mjs | 91 ++++-------- ...enerate-host-env-security-policy-swift.mjs | 5 +- scripts/ios-release-signing.mjs | 67 +++++---- scripts/ios-write-swift-filelist.mjs | 4 +- scripts/lib/arg-utils.d.mts | 18 ++- scripts/lib/arg-utils.mjs | 38 +++-- scripts/lib/callsite-guard.mjs | 7 +- .../lib/extension-import-boundary-checker.mjs | 7 +- scripts/lib/pairing-guard-context.mjs | 3 +- scripts/lib/plain-gh.d.mts | 26 ++++ scripts/lib/plain-gh.mjs | 15 +- scripts/lib/repo-root.d.mts | 2 + scripts/lib/repo-root.mjs | 20 +++ scripts/lib/report-cli-helpers.mjs | 64 +++----- scripts/lib/ts-guard-utils.d.mts | 4 - scripts/lib/ts-guard-utils.mjs | 21 +-- scripts/plugin-sdk-surface-report.mjs | 34 +++-- ...e-extension-package-boundary-artifacts.mjs | 4 +- scripts/profile-tsgo.mjs | 4 +- scripts/publish-model-catalog.mjs | 5 +- scripts/release-beta-smoke.ts | 87 ++++++----- scripts/release-candidate-checklist.mjs | 139 +++++++----------- .../resolve-openclaw-package-candidate.mjs | 98 ++++++------ scripts/run-android-gradle.mjs | 4 +- scripts/run-vitest.mjs | 4 +- scripts/runtime-postbuild.mjs | 5 +- scripts/sync-native-a2ui.mjs | 6 +- scripts/test-built-plugin-singleton.mjs | 5 +- scripts/verify-pr-hosted-gates.mjs | 88 +++++------ scripts/verify.mjs | 24 +-- scripts/watch-pr-ci.mjs | 31 ++-- test/scripts/arg-utils.test.ts | 25 ++++ test/scripts/check.test.ts | 14 +- .../full-release-validation-at-sha.test.ts | 26 +--- ...nerate-dependency-release-evidence.test.ts | 12 +- test/scripts/plain-gh.test.ts | 55 +++++++ ...lugin-npm-extended-stable-workflow.test.ts | 5 + .../scripts/plugin-sdk-surface-report.test.ts | 26 ++-- test/scripts/test-force.test.ts | 8 +- test/scripts/test-projects.test.ts | 1 + test/scripts/ts-guard-utils.test.ts | 20 ++- test/scripts/verify.test.ts | 16 +- 79 files changed, 742 insertions(+), 712 deletions(-) create mode 100644 scripts/lib/repo-root.d.mts create mode 100644 scripts/lib/repo-root.mjs diff --git a/scripts/android-release-signing.mjs b/scripts/android-release-signing.mjs index a5be21037df0..34bcba588605 100644 --- a/scripts/android-release-signing.mjs +++ b/scripts/android-release-signing.mjs @@ -2,10 +2,10 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; -import { fileURLToPath } from "node:url"; import { runAndroidSigningCommandSync } from "./lib/android-release-signing-process.mjs"; - -const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +import { parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const rootDir = resolveRepoRoot(import.meta.url); const defaultManifestPath = path.join(rootDir, "apps", "android", "Config", "ReleaseSigning.json"); const requiredPropertyNames = [ "OPENCLAW_ANDROID_STORE_FILE", @@ -46,33 +46,43 @@ function parseArgs(argv) { keystorePath: process.env.OPENCLAW_ANDROID_UPLOAD_KEYSTORE || "", propertiesPath: process.env.OPENCLAW_ANDROID_SIGNING_PROPERTIES || "", }; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === "--mode") { - options.mode = readOptionValue(argv, index, arg); - index += 1; - } else if (arg === "--manifest") { - options.manifestPath = path.resolve(readOptionValue(argv, index, arg)); - index += 1; - } else if (arg === "--workspace") { - options.workspace = path.resolve(readOptionValue(argv, index, arg)); - index += 1; - } else if (arg === "--materialized-dir") { - options.materializedDir = path.resolve(readOptionValue(argv, index, arg)); - index += 1; - } else if (arg === "--keystore") { - options.keystorePath = path.resolve(readOptionValue(argv, index, arg)); - index += 1; - } else if (arg === "--properties") { - options.propertiesPath = path.resolve(readOptionValue(argv, index, arg)); - index += 1; - } else if (arg === "-h" || arg === "--help") { - usage(); - process.exit(0); - } else { - throw new Error(`Unknown argument: ${arg}`); - } + const helpIndex = argv.findIndex((arg) => arg === "-h" || arg === "--help"); + parseFlagArgs( + helpIndex === -1 ? argv : argv.slice(0, helpIndex), + options, + [ + stringFlag("--mode", "mode", { + allowInline: false, + missingValueMessage: "Missing value for --mode.", + rejectShortOptions: true, + repeatable: true, + }), + ...[ + ["--manifest", "manifestPath"], + ["--workspace", "workspace"], + ["--materialized-dir", "materializedDir"], + ["--keystore", "keystorePath"], + ["--properties", "propertiesPath"], + ].map(([flag, key]) => + stringFlag(flag, key, { + allowInline: false, + missingValueMessage: `Missing value for ${flag}.`, + rejectShortOptions: true, + repeatable: true, + transform: path.resolve, + }), + ), + ], + { + ignoreDoubleDash: false, + onUnhandledArg(arg) { + throw new Error(`Unknown argument: ${arg}`); + }, + }, + ); + if (helpIndex !== -1) { + usage(); + process.exit(0); } if (!options.mode) { @@ -82,14 +92,6 @@ function parseArgs(argv) { return options; } -function readOptionValue(argv, index, option) { - const value = argv[index + 1] ?? ""; - if (!value || value.startsWith("-")) { - throw new Error(`Missing value for ${option}.`); - } - return value; -} - function requireString(value, key) { if (typeof value !== "string" || value.trim() === "") { throw new Error(`Android release signing manifest missing ${key}.`); diff --git a/scripts/audit-seams.mjs b/scripts/audit-seams.mjs index 2416da640d04..27dc80e9d21a 100644 --- a/scripts/audit-seams.mjs +++ b/scripts/audit-seams.mjs @@ -12,9 +12,9 @@ import { import { visitModuleSpecifiers } from "./lib/guard-inventory-utils.mjs"; import { optionalBundledClusterSet } from "./lib/optional-bundled-clusters.mjs"; import { escapeRegExp } from "./lib/regexp.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { toLine } from "./lib/ts-guard-utils.mjs"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const srcRoot = path.join(repoRoot, "src"); const extensionsRoot = path.join(repoRoot, BUNDLED_PLUGIN_ROOT_DIR); const testRoot = path.join(repoRoot, "test"); diff --git a/scripts/bundled-plugin-assets.mjs b/scripts/bundled-plugin-assets.mjs index 9147bd94f1b1..b70b50a32cfb 100644 --- a/scripts/bundled-plugin-assets.mjs +++ b/scripts/bundled-plugin-assets.mjs @@ -4,12 +4,12 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { pathToFileURL } from "node:url"; import { runManagedCommand } from "./lib/managed-child-process.mjs"; import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mjs"; - -const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const rootDir = resolveRepoRoot(import.meta.url); const VALID_PHASES = new Set(["build", "copy"]); // Each complete bundled-plugin asset generator gets the same 10-minute build ceiling. const BUNDLED_PLUGIN_ASSET_HOOK_TIMEOUT_MS = 600_000; diff --git a/scripts/check-channel-agnostic-boundaries.mjs b/scripts/check-channel-agnostic-boundaries.mjs index b3a7be44cf54..682abe6128e7 100644 --- a/scripts/check-channel-agnostic-boundaries.mjs +++ b/scripts/check-channel-agnostic-boundaries.mjs @@ -5,10 +5,10 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import ts from "typescript"; import { visitModuleSpecifiers } from "./lib/guard-inventory-utils.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { collectTypeScriptFiles, getPropertyNameText, - resolveRepoRoot, runAsScript, toLine, } from "./lib/ts-guard-utils.mjs"; diff --git a/scripts/check-cli-startup-memory.mjs b/scripts/check-cli-startup-memory.mjs index 8b547ca7aafd..03d45d5a3b4d 100644 --- a/scripts/check-cli-startup-memory.mjs +++ b/scripts/check-cli-startup-memory.mjs @@ -5,9 +5,9 @@ import { spawnSync as defaultSpawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +import { pathToFileURL } from "node:url"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const repoRoot = resolveRepoRoot(import.meta.url); const tmpDir = process.env.TMPDIR || process.env.TEMP || process.env.TMP || os.tmpdir(); const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__="; const DEFAULT_COMMAND_TIMEOUT_MS = 60_000; diff --git a/scripts/check-control-ui-precompressed-assets.mjs b/scripts/check-control-ui-precompressed-assets.mjs index 38c9dca2a54b..f2fe536c9be5 100644 --- a/scripts/check-control-ui-precompressed-assets.mjs +++ b/scripts/check-control-ui-precompressed-assets.mjs @@ -2,10 +2,9 @@ // Verifies each generated Control UI sidecar encodes the final emitted asset bytes. import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { brotliDecompressSync, gunzipSync } from "node:zlib"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const repoRoot = resolveRepoRoot(import.meta.url); const assetsDir = path.join(repoRoot, "dist", "control-ui", "assets"); const errors = []; let checked = 0; diff --git a/scripts/check-database-first-legacy-stores.mjs b/scripts/check-database-first-legacy-stores.mjs index f492afa39961..e57277f94849 100644 --- a/scripts/check-database-first-legacy-stores.mjs +++ b/scripts/check-database-first-legacy-stores.mjs @@ -12,7 +12,8 @@ import { mergeLegacyObjectPropertyValues, mergeLegacyPathBranchAssignments, } from "./lib/legacy-store-path-domain.mjs"; -import { resolveRepoRoot, runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +import { runAsScript, toLine, unwrapExpression } from "./lib/ts-guard-utils.mjs"; const databaseFirstLegacyStoreSourceRoots = ["src", "extensions", "packages"]; const databaseFirstNativeSourceRoots = ["apps/macos/Sources/OpenClaw"]; diff --git a/scripts/check-deprecated-jsdoc.mjs b/scripts/check-deprecated-jsdoc.mjs index c1a7d86e72ec..52bf9242df78 100644 --- a/scripts/check-deprecated-jsdoc.mjs +++ b/scripts/check-deprecated-jsdoc.mjs @@ -3,12 +3,12 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; const require = createRequire(import.meta.url); const ts = require("typescript"); -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const SCAN_ROOTS = ["src", "extensions", "packages"]; const SOURCE_FILE_RE = /\.(?:ts|tsx)$/; const SKIP_PATH_RE = diff --git a/scripts/check-docker-e2e-boundaries.mjs b/scripts/check-docker-e2e-boundaries.mjs index 659e9700b2c5..246c279297a1 100644 --- a/scripts/check-docker-e2e-boundaries.mjs +++ b/scripts/check-docker-e2e-boundaries.mjs @@ -4,7 +4,6 @@ // the source checkout copied or mounted as the app under test. import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { laneResources, laneWeight } from "./lib/docker-e2e-plan.mjs"; import { allReleasePathLanes, @@ -12,8 +11,8 @@ import { publicInstallerLanes, tailLanes, } from "./lib/docker-e2e-scenarios.mjs"; - -const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const ROOT_DIR = resolveRepoRoot(import.meta.url); const errors = []; const packageJson = JSON.parse(readText("package.json")); const packageScripts = new Set(Object.keys(packageJson.scripts ?? {})); diff --git a/scripts/check-duplicates.mjs b/scripts/check-duplicates.mjs index 892ccc59a7c6..460e14031fcc 100644 --- a/scripts/check-duplicates.mjs +++ b/scripts/check-duplicates.mjs @@ -2,9 +2,8 @@ // Runs duplicate-code detection with repo-specific excludes. import { spawnSync } from "node:child_process"; import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const repoRoot = resolveRepoRoot(import.meta.url); const jscpdBin = path.join(repoRoot, "node_modules", "jscpd", "bin", "jscpd"); const targets = [ diff --git a/scripts/check-dynamic-import-warts.mjs b/scripts/check-dynamic-import-warts.mjs index 35adcab82e9a..691659580c0a 100644 --- a/scripts/check-dynamic-import-warts.mjs +++ b/scripts/check-dynamic-import-warts.mjs @@ -4,12 +4,8 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import ts from "typescript"; -import { - collectTypeScriptFilesFromRoots, - resolveRepoRoot, - runAsScript, - toLine, -} from "./lib/ts-guard-utils.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +import { collectTypeScriptFilesFromRoots, runAsScript, toLine } from "./lib/ts-guard-utils.mjs"; const repoRoot = resolveRepoRoot(import.meta.url); const defaultRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")]; diff --git a/scripts/check-extension-package-tsc-boundary.mjs b/scripts/check-extension-package-tsc-boundary.mjs index f5c2a1c7e4ae..f489db0bbbe3 100644 --- a/scripts/check-extension-package-tsc-boundary.mjs +++ b/scripts/check-extension-package-tsc-boundary.mjs @@ -16,6 +16,7 @@ import os from "node:os"; import path, { dirname, join, resolve } from "node:path"; import pMap from "p-map"; import { parsePositiveInt } from "./lib/numeric-options.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { forwardSignalToVitestProcessGroup, installVitestProcessGroupCleanup, @@ -23,7 +24,7 @@ import { } from "./vitest-process-group.mjs"; const require = createRequire(import.meta.url); -const repoRoot = resolve(import.meta.dirname, ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const tscBin = require.resolve("typescript/bin/tsc"); const nativePreviewPackageJsonPath = require.resolve("@typescript/native-preview/package.json"); const nativePreviewPackageJson = JSON.parse(readFileSync(nativePreviewPackageJsonPath, "utf8")); diff --git a/scripts/check-extension-plugin-sdk-boundary.mjs b/scripts/check-extension-plugin-sdk-boundary.mjs index a10d3188575b..9e172c90d2de 100644 --- a/scripts/check-extension-plugin-sdk-boundary.mjs +++ b/scripts/check-extension-plugin-sdk-boundary.mjs @@ -15,8 +15,9 @@ import { resolveRepoSpecifier, writeLine, } from "./lib/guard-inventory-utils.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mjs"; -import { resolveRepoRoot, runAsScript } from "./lib/ts-guard-utils.mjs"; +import { runAsScript } from "./lib/ts-guard-utils.mjs"; const repoRoot = resolveRepoRoot(import.meta.url); // Generated bundles are validated at their build owner; they are not bounded authored source. diff --git a/scripts/check-extension-wildcard-reexports.mjs b/scripts/check-extension-wildcard-reexports.mjs index 3b9bbebe5000..b6511f7594d1 100644 --- a/scripts/check-extension-wildcard-reexports.mjs +++ b/scripts/check-extension-wildcard-reexports.mjs @@ -4,8 +4,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const repoRoot = resolveRepoRoot(import.meta.url); const LOCAL_WILDCARD_REEXPORT_PATTERN = /^\s*export\s+(?:type\s+)?\*\s+from\s+["'](?:\.{1,2}\/)/u; diff --git a/scripts/check-kysely-guardrails.mjs b/scripts/check-kysely-guardrails.mjs index de91253a36cf..08bea26594ff 100644 --- a/scripts/check-kysely-guardrails.mjs +++ b/scripts/check-kysely-guardrails.mjs @@ -4,10 +4,10 @@ import { promises as fs } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { collectTypeScriptFilesFromRoots, getPropertyNameText, - resolveRepoRoot, runAsScript, toLine, unwrapExpression, diff --git a/scripts/check-plugin-extension-import-boundary.mjs b/scripts/check-plugin-extension-import-boundary.mjs index b824675d854d..9931e36f3565 100644 --- a/scripts/check-plugin-extension-import-boundary.mjs +++ b/scripts/check-plugin-extension-import-boundary.mjs @@ -11,7 +11,8 @@ import { runBaselineInventoryCheck, resolveRepoSpecifier, } from "./lib/guard-inventory-utils.mjs"; -import { resolveRepoRoot, runAsScript } from "./lib/ts-guard-utils.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +import { runAsScript } from "./lib/ts-guard-utils.mjs"; const repoRoot = resolveRepoRoot(import.meta.url); const baselinePath = path.join( diff --git a/scripts/check-plugin-sdk-subpath-exports.mjs b/scripts/check-plugin-sdk-subpath-exports.mjs index 1a09eb8e157d..0474d003919f 100644 --- a/scripts/check-plugin-sdk-subpath-exports.mjs +++ b/scripts/check-plugin-sdk-subpath-exports.mjs @@ -3,16 +3,15 @@ // Verifies plugin SDK subpath exports and generated entrypoint metadata. import { readFileSync } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import ts from "typescript"; import { normalizeRepoPath, visitModuleSpecifiers } from "./lib/guard-inventory-utils.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { collectTypeScriptFilesFromRoots, resolveSourceRoots, toLine, } from "./lib/ts-guard-utils.mjs"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const scanRoots = resolveSourceRoots(repoRoot, [ "src", "packages", diff --git a/scripts/check-plugin-sdk-wildcard-reexports.mjs b/scripts/check-plugin-sdk-wildcard-reexports.mjs index 70c520bde4ee..7839b2b8f86c 100644 --- a/scripts/check-plugin-sdk-wildcard-reexports.mjs +++ b/scripts/check-plugin-sdk-wildcard-reexports.mjs @@ -4,8 +4,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const repoRoot = resolveRepoRoot(import.meta.url); const extensionsRoot = path.join(repoRoot, "extensions"); const WILDCARD_PLUGIN_SDK_REEXPORT_PATTERN = diff --git a/scripts/check-protocol-registry.mjs b/scripts/check-protocol-registry.mjs index da0f8d7c6825..b74854d3e9d8 100644 --- a/scripts/check-protocol-registry.mjs +++ b/scripts/check-protocol-registry.mjs @@ -1,8 +1,8 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +import { pathToFileURL } from "node:url"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const repoRoot = resolveRepoRoot(import.meta.url); const schemaDir = path.join(repoRoot, "packages/gateway-protocol/src/schema"); const failures = []; const read = (relativePath) => fs.readFileSync(path.join(repoRoot, relativePath), "utf8"); diff --git a/scripts/check-protocol-since.mjs b/scripts/check-protocol-since.mjs index ad08083f5dc9..23b474804df4 100644 --- a/scripts/check-protocol-since.mjs +++ b/scripts/check-protocol-since.mjs @@ -4,12 +4,12 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; const require = createRequire(import.meta.url); const ts = require("typescript"); -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const descriptorPath = "src/gateway/methods/core-descriptors.ts"; function runGit(args) { diff --git a/scripts/check-runtime-sidecar-loaders.mjs b/scripts/check-runtime-sidecar-loaders.mjs index 5e8bcd5d9cb0..1f8684f60f05 100644 --- a/scripts/check-runtime-sidecar-loaders.mjs +++ b/scripts/check-runtime-sidecar-loaders.mjs @@ -4,9 +4,9 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import ts from "typescript"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { collectTypeScriptFilesFromRoots, - resolveRepoRoot, runAsScript, toLine, unwrapExpression, diff --git a/scripts/check-session-accessor-boundary.mjs b/scripts/check-session-accessor-boundary.mjs index 372c15425be2..7b45b6072c5f 100644 --- a/scripts/check-session-accessor-boundary.mjs +++ b/scripts/check-session-accessor-boundary.mjs @@ -3,10 +3,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import ts from "typescript"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { collectFileViolations, getPropertyNameText, - resolveRepoRoot, resolveSourceRoots, runAsScript, toLine, diff --git a/scripts/check-session-transcript-reader-boundary.mjs b/scripts/check-session-transcript-reader-boundary.mjs index 42d996872aa0..254b25c72ada 100644 --- a/scripts/check-session-transcript-reader-boundary.mjs +++ b/scripts/check-session-transcript-reader-boundary.mjs @@ -2,9 +2,9 @@ import path from "node:path"; import ts from "typescript"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { collectFileViolations, - resolveRepoRoot, resolveSourceRoots, runAsScript, toLine, diff --git a/scripts/check-sqlite-transaction-boundary.mjs b/scripts/check-sqlite-transaction-boundary.mjs index 183f7cfaa6de..c4f7c112c2c2 100644 --- a/scripts/check-sqlite-transaction-boundary.mjs +++ b/scripts/check-sqlite-transaction-boundary.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node import ts from "typescript"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { collectFileViolations, - resolveRepoRoot, resolveSourceRoots, runAsScript, toLine, diff --git a/scripts/check-telegram-grammy-types-imports.mjs b/scripts/check-telegram-grammy-types-imports.mjs index f3cbd323eef5..76e7d6dd64c0 100644 --- a/scripts/check-telegram-grammy-types-imports.mjs +++ b/scripts/check-telegram-grammy-types-imports.mjs @@ -2,8 +2,8 @@ // Prevents Telegram runtime imports from grammy type-only modules. import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; - -const repoRoot = path.resolve(import.meta.dirname, ".."); +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const repoRoot = resolveRepoRoot(import.meta.url); const telegramRoot = path.join(repoRoot, "extensions/telegram"); const importSpecifierPatterns = [ /\bimport\s+(?:type\s+)?[\s\S]*?\bfrom\s*["']([^"']+)["']/gu, diff --git a/scripts/check-tsgo-core-boundary.mjs b/scripts/check-tsgo-core-boundary.mjs index bb834e4d0dbe..2bc1fe296c42 100644 --- a/scripts/check-tsgo-core-boundary.mjs +++ b/scripts/check-tsgo-core-boundary.mjs @@ -2,11 +2,10 @@ // Enforces core tsgo project boundaries and sparse-checkout safety. import { spawnSync } from "node:child_process"; -import path from "node:path"; import { resolveRepoToolBinPath } from "./lib/local-heavy-check-runtime.mjs"; import { createManagedCommandInvocation } from "./lib/managed-child-process.mjs"; - -const repoRoot = path.resolve(import.meta.dirname, ".."); +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const repoRoot = resolveRepoRoot(import.meta.url); const tsgoPath = resolveRepoToolBinPath("tsgo", { cwd: repoRoot }); const coreGraphs = [ diff --git a/scripts/check-web-fetch-provider-boundaries.mjs b/scripts/check-web-fetch-provider-boundaries.mjs index e1f5e6eaa01f..170dddbed428 100644 --- a/scripts/check-web-fetch-provider-boundaries.mjs +++ b/scripts/check-web-fetch-provider-boundaries.mjs @@ -1,12 +1,10 @@ #!/usr/bin/env node // Checks core web-fetch surfaces for provider-owned Firecrawl coupling. -import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { collectSourceFileContents } from "./lib/source-file-scan-cache.mjs"; import { runAsScript } from "./lib/ts-guard-utils.mjs"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const scanExtensions = new Set([".ts", ".js", ".mjs", ".cjs"]); const ignoredDirNames = new Set([ ".artifacts", diff --git a/scripts/check-web-search-provider-boundaries.mjs b/scripts/check-web-search-provider-boundaries.mjs index d7de4fa51e02..9eecd8acba15 100644 --- a/scripts/check-web-search-provider-boundaries.mjs +++ b/scripts/check-web-search-provider-boundaries.mjs @@ -3,12 +3,11 @@ // Inventories core web-search surfaces that still mention bundled providers. import { promises as fs } from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { diffInventoryEntries, runBaselineInventoryCheck } from "./lib/guard-inventory-utils.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { collectSourceFileContents } from "./lib/source-file-scan-cache.mjs"; import { runAsScript } from "./lib/ts-guard-utils.mjs"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const baselinePath = path.join( repoRoot, "test", diff --git a/scripts/check.mjs b/scripts/check.mjs index 62454a548b36..c239e526940a 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -1,5 +1,6 @@ // Runs the repository check lanes selected by CLI arguments. import { performance } from "node:perf_hooks"; +import { booleanFlag, parseFlagArgs } from "./lib/arg-utils.mjs"; import { printTimingSummary } from "./lib/check-timing-summary.mjs"; import { runManagedCommand } from "./lib/managed-child-process.mjs"; @@ -24,26 +25,23 @@ export function usage() { * Parses aggregate check runner arguments. */ function parseCheckArgs(argv) { - const args = { - help: false, - includeArchitecture: false, - includeTestTypes: false, - timed: false, - }; - for (const arg of argv) { - if (arg === "--timed") { - args.timed = true; - } else if (arg === "--include-architecture") { - args.includeArchitecture = true; - } else if (arg === "--include-test-types") { - args.includeTestTypes = true; - } else if (arg === "--help" || arg === "-h") { - args.help = true; - } else { - throw new Error(`unknown argument: ${arg}\n\n${usage()}`); - } - } - return args; + return parseFlagArgs( + argv, + { help: false, includeArchitecture: false, includeTestTypes: false, timed: false }, + [ + booleanFlag("--timed", "timed", true, { repeatable: true }), + booleanFlag("--include-architecture", "includeArchitecture", true, { repeatable: true }), + booleanFlag("--include-test-types", "includeTestTypes", true, { repeatable: true }), + booleanFlag("--help", "help", true, { repeatable: true }), + booleanFlag("-h", "help", true, { repeatable: true }), + ], + { + ignoreDoubleDash: false, + onUnhandledArg(arg) { + throw new Error(`unknown argument: ${arg}\n\n${usage()}`); + }, + }, + ); } /** diff --git a/scripts/docs-sync-publish.mjs b/scripts/docs-sync-publish.mjs index bab5500734ef..b99dd3dad4ee 100644 --- a/scripts/docs-sync-publish.mjs +++ b/scripts/docs-sync-publish.mjs @@ -4,12 +4,12 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { pathToFileURL } from "node:url"; import { renderDocsHeadingMap } from "./docs-list.js"; import { repairMintlifyAccordionIndentation } from "./lib/mintlify-accordion.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const ROOT = path.resolve(HERE, ".."); +const ROOT = resolveRepoRoot(import.meta.url); const SOURCE_DOCS_DIR = path.join(ROOT, "docs"); const SOURCE_CONFIG_PATH = path.join(SOURCE_DOCS_DIR, "docs.json"); const INTERNAL_DOCS_DIRS = ["internal"]; diff --git a/scripts/e2e-sandbox-bind-conflict.mjs b/scripts/e2e-sandbox-bind-conflict.mjs index 38dc2783e93d..2ee73867244d 100644 --- a/scripts/e2e-sandbox-bind-conflict.mjs +++ b/scripts/e2e-sandbox-bind-conflict.mjs @@ -12,10 +12,9 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; -const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(scriptDir, ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const engine = process.env.OPENCLAW_SANDBOX_E2E_ENGINE?.trim() || "docker"; const image = process.env.OPENCLAW_SANDBOX_E2E_IMAGE?.trim() || "e2e-sleep:latest"; const useSudo = process.env.OPENCLAW_SANDBOX_E2E_SUDO === "1"; diff --git a/scripts/ensure-cli-startup-build.mjs b/scripts/ensure-cli-startup-build.mjs index 879960b67491..cff45f1e7b05 100644 --- a/scripts/ensure-cli-startup-build.mjs +++ b/scripts/ensure-cli-startup-build.mjs @@ -4,10 +4,10 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { pathToFileURL } from "node:url"; import { readPositiveEnvInt } from "./lib/numeric-options.mjs"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const repoRoot = resolveRepoRoot(import.meta.url); const entryCandidates = ["dist/entry.js", "dist/entry.mjs"]; const startupMetadataPath = "dist/cli-startup-metadata.json"; const DEFAULT_BUILD_TIMEOUT_MS = 10 * 60 * 1000; diff --git a/scripts/ensure-extension-memory-build.mjs b/scripts/ensure-extension-memory-build.mjs index 533623d4da9c..b12dd4d37315 100644 --- a/scripts/ensure-extension-memory-build.mjs +++ b/scripts/ensure-extension-memory-build.mjs @@ -4,14 +4,14 @@ import { spawnSync } from "node:child_process"; import { existsSync, readdirSync } from "node:fs"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { pathToFileURL } from "node:url"; import { collectBundledPluginBuildEntries, NON_PACKAGED_BUNDLED_PLUGIN_DIRS, } from "./lib/bundled-plugin-build-entries.mjs"; import { readPositiveEnvInt } from "./lib/numeric-options.mjs"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const repoRoot = resolveRepoRoot(import.meta.url); const DEFAULT_BUILD_TIMEOUT_MS = 10 * 60 * 1000; /** diff --git a/scripts/ensure-playwright-chromium.mjs b/scripts/ensure-playwright-chromium.mjs index 4c2f0e23c800..9d64b8f783c1 100644 --- a/scripts/ensure-playwright-chromium.mjs +++ b/scripts/ensure-playwright-chromium.mjs @@ -2,12 +2,13 @@ // Ensures Playwright Chromium is installed or a usable system browser is available. import { spawnSync as spawnSyncImpl } from "node:child_process"; import { existsSync as existsSyncImpl, realpathSync } from "node:fs"; -import { dirname, resolve } from "node:path"; +import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { chromium } from "playwright"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { resolvePnpmRunner } from "./pnpm-runner.mjs"; -const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const playwrightInstallBaseArgs = ["--dir", "ui", "exec", "playwright", "install"]; const executableOverrideEnvKey = "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"; const chromiumPackageNames = ["chromium-browser", "chromium"]; diff --git a/scripts/format-docs.mjs b/scripts/format-docs.mjs index d802eee5f057..11d99ba5b2ce 100644 --- a/scripts/format-docs.mjs +++ b/scripts/format-docs.mjs @@ -9,9 +9,9 @@ import { pathToFileURL } from "node:url"; import { resolveRepoToolBinPath } from "./lib/local-heavy-check-runtime.mjs"; import { repairMintlifyAccordionIndentation } from "./lib/mintlify-accordion.mjs"; import { outputTail } from "./lib/output-tail.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs"; - -const ROOT = path.resolve(import.meta.dirname, ".."); +const ROOT = resolveRepoRoot(import.meta.url); const CHECK = process.argv.includes("--check"); const DOCS_FORMAT_MAX_BUFFER_BYTES = 1024 * 1024 * 16; const DOCS_FORMAT_MAX_COMMAND_LINE_BYTES = 24 * 1024; diff --git a/scripts/full-release-validation-at-sha.d.mts b/scripts/full-release-validation-at-sha.d.mts index c3de6e8bc6df..938401db2cb6 100644 --- a/scripts/full-release-validation-at-sha.d.mts +++ b/scripts/full-release-validation-at-sha.d.mts @@ -19,13 +19,6 @@ export function releaseProfileForTarget( readPackageJson?: (sha: string) => string, ): "beta" | "stable"; export function releaseEvidenceVerificationArgs(parentRunId: unknown): string[]; -export function runGhRead( - args: string[], - params?: { - execFileSyncImpl?: (...args: unknown[]) => unknown; - timeoutMs?: number; - }, -): string; export function shouldDeleteTemporaryWorkflowRef(params: { keepBranch: boolean; dryRun: boolean; diff --git a/scripts/full-release-validation-at-sha.mjs b/scripts/full-release-validation-at-sha.mjs index 3179fd261e10..bc5fd214ca69 100755 --- a/scripts/full-release-validation-at-sha.mjs +++ b/scripts/full-release-validation-at-sha.mjs @@ -5,6 +5,7 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; +import { execGhRead } from "./lib/plain-gh.mjs"; const WORKFLOW = "full-release-validation.yml"; const TRUSTED_WORKFLOW_PATH = `.github/workflows/${WORKFLOW}`; @@ -13,6 +14,12 @@ const RELEASE_EVIDENCE_VERIFIER_PATHS = [ ".agents/skills/release-openclaw-ci/scripts/release-ci-summary.mjs", ]; const GH_READ_TIMEOUT_MS = 60_000; +const GH_READ_OPTIONS = { + encoding: "utf8", + killSignal: "SIGKILL", + stdio: ["ignore", "pipe", "inherit"], + timeout: GH_READ_TIMEOUT_MS, +}; const RELEASE_BRANCH_PATTERN = /^(?:release\/[0-9]{4}\.[0-9]+\.[0-9]+|extended-stable\/[0-9]{4}\.[0-9]+\.33)$/u; const RELEASE_TAG_PATTERN = /^v[0-9]{4}\.[0-9]+\.[0-9]+(?:-(?:alpha|beta)\.[0-9]+)?$/u; @@ -62,17 +69,6 @@ function runStatus(command, args, options = {}) { }); } -export function runGhRead(args, params = {}) { - const execFileSyncImpl = params.execFileSyncImpl ?? execFileSync; - const output = execFileSyncImpl("gh", args, { - encoding: "utf8", - killSignal: "SIGKILL", - stdio: ["ignore", "pipe", "inherit"], - timeout: params.timeoutMs ?? GH_READ_TIMEOUT_MS, - }); - return typeof output === "string" ? output.trim() : ""; -} - function readOptionValue(argv, index, optionName) { const value = argv[index + 1]; if (value === undefined || value === "" || value.startsWith("-")) { @@ -264,20 +260,23 @@ function collectRunId(dispatchOutput) { } function findLatestRunId(branch, sha) { - const json = runGhRead([ - "run", - "list", - "--workflow", - WORKFLOW, - "--branch", - branch, - "--event", - "workflow_dispatch", - "--limit", - "20", - "--json", - "databaseId,headSha,createdAt", - ]); + const json = execGhRead( + [ + "run", + "list", + "--workflow", + WORKFLOW, + "--branch", + branch, + "--event", + "workflow_dispatch", + "--limit", + "20", + "--json", + "databaseId,headSha,createdAt", + ], + GH_READ_OPTIONS, + ); const runs = JSON.parse(json); const match = runs.find((runItem) => runItem.headSha === sha); return match?.databaseId ? String(match.databaseId) : ""; @@ -288,7 +287,7 @@ function readWorkflowRun(parentRunId, workflowSha) { throw new Error("parent run ID must be a positive decimal"); } const workflowRun = JSON.parse( - runGhRead(["api", `repos/openclaw/openclaw/actions/runs/${parentRunId}`]), + execGhRead(["api", `repos/openclaw/openclaw/actions/runs/${parentRunId}`], GH_READ_OPTIONS), ); if (workflowRun.head_sha !== workflowSha) { throw new Error( diff --git a/scripts/generate-dependency-release-evidence.mjs b/scripts/generate-dependency-release-evidence.mjs index e6e867d1486b..13fa3791d853 100644 --- a/scripts/generate-dependency-release-evidence.mjs +++ b/scripts/generate-dependency-release-evidence.mjs @@ -5,6 +5,7 @@ import { execFileSync } from "node:child_process"; import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; +import { parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs"; /** * Dependency evidence reports generated for release artifacts. @@ -379,14 +380,6 @@ async function generateDependencyReleaseEvidence({ return { manifest, counts, outputDir }; } -function readOptionValue(argv, index, optionName, { allowEmpty = false } = {}) { - const value = argv[index + 1]; - if (value === undefined || value.startsWith("-") || (!allowEmpty && value === "")) { - throw new Error(`Expected ${optionName} .`); - } - return value; -} - function usage() { return `Usage: node scripts/generate-dependency-release-evidence.mjs --output-dir --release-ref --npm-dist-tag [options] @@ -414,60 +407,34 @@ export function parseArgs(argv) { githubOutput: process.env.GITHUB_OUTPUT, githubStepSummary: process.env.GITHUB_STEP_SUMMARY, }; - const seen = new Set(); - const setOnce = (flag, key, value) => { - if (seen.has(flag)) { - throw new Error(`${flag} was provided more than once.`); - } - seen.add(flag); - options[key] = value; - }; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === "--") { - continue; - } - if (arg === "-h" || arg === "--help") { - return { ...options, help: true }; - } - if (arg === "--root") { - setOnce(arg, "rootDir", readOptionValue(argv, index, arg)); - index += 1; - continue; - } - if (arg === "--output-dir") { - setOnce(arg, "outputDir", readOptionValue(argv, index, arg)); - index += 1; - continue; - } - if (arg === "--release-ref") { - setOnce(arg, "releaseRef", readOptionValue(argv, index, arg)); - index += 1; - continue; - } - if (arg === "--npm-dist-tag") { - setOnce(arg, "npmDistTag", readOptionValue(argv, index, arg)); - index += 1; - continue; - } - if (arg === "--base-ref") { - setOnce(arg, "baseRef", readOptionValue(argv, index, arg)); - index += 1; - continue; - } - if (arg === "--github-output") { - setOnce(arg, "githubOutput", readOptionValue(argv, index, arg, { allowEmpty: true })); - index += 1; - continue; - } - if (arg === "--github-step-summary") { - setOnce(arg, "githubStepSummary", readOptionValue(argv, index, arg, { allowEmpty: true })); - index += 1; - continue; - } - throw new Error(`Unsupported argument: ${arg}`); - } - return options; + const helpIndex = argv.findIndex((arg) => arg === "-h" || arg === "--help"); + const parsed = parseFlagArgs( + helpIndex === -1 ? argv : argv.slice(0, helpIndex), + options, + [ + ["--root", "rootDir", false], + ["--output-dir", "outputDir", false], + ["--release-ref", "releaseRef", false], + ["--npm-dist-tag", "npmDistTag", false], + ["--base-ref", "baseRef", false], + ["--github-output", "githubOutput", true], + ["--github-step-summary", "githubStepSummary", true], + ].map(([flag, key, allowEmpty]) => + stringFlag(flag, key, { + allowEmpty, + allowInline: false, + missingValueMessage: `Expected ${flag} .`, + rejectShortOptions: true, + }), + ), + { + duplicateOptionMessage: (flag) => `${flag} was provided more than once.`, + onUnhandledArg(arg) { + throw new Error(`Unsupported argument: ${arg}`); + }, + }, + ); + return helpIndex === -1 ? parsed : { ...parsed, help: true }; } /** diff --git a/scripts/generate-host-env-security-policy-swift.mjs b/scripts/generate-host-env-security-policy-swift.mjs index 7d2f7404edc7..daa2375df6b6 100644 --- a/scripts/generate-host-env-security-policy-swift.mjs +++ b/scripts/generate-host-env-security-policy-swift.mjs @@ -2,8 +2,8 @@ // Generates Swift constants for the host environment security policy. import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { loadHostEnvSecurityPolicy } from "../src/infra/host-env-security-policy.js"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; const args = new Set(process.argv.slice(2)); const checkOnly = args.has("--check"); @@ -14,8 +14,7 @@ if (checkOnly && args.has("--write")) { process.exit(1); } -const here = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(here, ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const policyPath = path.join(repoRoot, "src", "infra", "host-env-security-policy.json"); const outputPath = path.join( repoRoot, diff --git a/scripts/ios-release-signing.mjs b/scripts/ios-release-signing.mjs index 42f980e7bf7d..8487932c3409 100755 --- a/scripts/ios-release-signing.mjs +++ b/scripts/ios-release-signing.mjs @@ -2,9 +2,9 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; -import { fileURLToPath } from "node:url"; - -const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +import { parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const rootDir = resolveRepoRoot(import.meta.url); const defaultManifestPath = path.join(rootDir, "apps", "ios", "Config", "AppStoreSigning.json"); function validateAppGroupId(value, context) { @@ -31,38 +31,41 @@ validates the checked-in manifest and renders local release xcconfig settings. } function parseArgs(argv) { - let mode = ""; - let manifestPath = defaultManifestPath; - - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (arg === "--mode") { - mode = readOptionValue(argv, i, arg); - i += 1; - } else if (arg === "--manifest") { - manifestPath = path.resolve(readOptionValue(argv, i, arg)); - i += 1; - } else if (arg === "-h" || arg === "--help") { - usage(); - process.exit(0); - } else { - throw new Error(`Unknown argument: ${arg}`); - } + const options = { manifestPath: defaultManifestPath, mode: "" }; + const helpIndex = argv.findIndex((arg) => arg === "-h" || arg === "--help"); + parseFlagArgs( + helpIndex === -1 ? argv : argv.slice(0, helpIndex), + options, + [ + stringFlag("--mode", "mode", { + allowInline: false, + missingValueMessage: "Missing value for --mode.", + rejectShortOptions: true, + repeatable: true, + }), + stringFlag("--manifest", "manifestPath", { + allowInline: false, + missingValueMessage: "Missing value for --manifest.", + rejectShortOptions: true, + repeatable: true, + transform: path.resolve, + }), + ], + { + ignoreDoubleDash: false, + onUnhandledArg(arg) { + throw new Error(`Unknown argument: ${arg}`); + }, + }, + ); + if (helpIndex !== -1) { + usage(); + process.exit(0); } - - if (!mode) { + if (!options.mode) { throw new Error("Missing required --mode."); } - - return { mode, manifestPath }; -} - -function readOptionValue(argv, index, option) { - const value = argv[index + 1] ?? ""; - if (!value || value.startsWith("-")) { - throw new Error(`Missing value for ${option}.`); - } - return value; + return options; } function readManifest(manifestPath) { diff --git a/scripts/ios-write-swift-filelist.mjs b/scripts/ios-write-swift-filelist.mjs index 005576415bd6..1be8e371b839 100644 --- a/scripts/ios-write-swift-filelist.mjs +++ b/scripts/ios-write-swift-filelist.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node import { existsSync, lstatSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; import path from "node:path"; - -const repoRoot = path.resolve(import.meta.dirname, ".."); +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const repoRoot = resolveRepoRoot(import.meta.url); const iosRoot = path.join(repoRoot, "apps", "ios"); const outputPath = path.join(iosRoot, "SwiftSources.input.xcfilelist"); diff --git a/scripts/lib/arg-utils.d.mts b/scripts/lib/arg-utils.d.mts index e0245a00bfec..249d461ba747 100644 --- a/scripts/lib/arg-utils.d.mts +++ b/scripts/lib/arg-utils.d.mts @@ -17,12 +17,24 @@ export function stripLeadingPackageManagerSeparator(argv: string[]): string[]; export function stringFlag( flag: string, key: string, - options?: { rejectShortOptions?: boolean }, + options?: { + allowEmpty?: boolean; + allowInline?: boolean; + missingValueMessage?: string; + rejectShortOptions?: boolean; + repeatable?: boolean; + transform?: (value: string) => unknown; + }, ): FlagSpec; export function stringListFlag( flag: string, key: string, - options?: { rejectShortOptions?: boolean }, + options?: { + allowEmpty?: boolean; + allowInline?: boolean; + missingValueMessage?: string; + rejectShortOptions?: boolean; + }, ): FlagSpec; export function intFlag( flag: string, @@ -33,6 +45,7 @@ export function booleanFlag( flag: string, key: string, value?: unknown, + options?: { repeatable?: boolean }, ): FlagSpec; export function parseFlagArgs( argv: readonly string[], @@ -40,6 +53,7 @@ export function parseFlagArgs( specs: readonly FlagSpec[], options?: { allowUnknownOptions?: boolean; + duplicateOptionMessage?: (flag: string) => string; ignoreDoubleDash?: boolean; onUnhandledArg?: (arg: string, args: T) => "handled" | void; }, diff --git a/scripts/lib/arg-utils.mjs b/scripts/lib/arg-utils.mjs index 6bd038273ed0..032dbf29b793 100644 --- a/scripts/lib/arg-utils.mjs +++ b/scripts/lib/arg-utils.mjs @@ -1,4 +1,7 @@ // Shared argument parsing helpers for repository scripts. +function failFlagParse(message) { + throw new Error(message); +} /** * Read a flag value from `--flag value` or `--flag=value` arguments. * @internal Shared repository-script contract. @@ -25,7 +28,7 @@ export function stripLeadingPackageManagerSeparator(argv) { } function isMissingStringFlagValue(value, options = {}) { - if (!value) { + if (value === undefined || (!value && options.allowEmpty !== true)) { return true; } if (value.startsWith("--")) { @@ -35,10 +38,10 @@ function isMissingStringFlagValue(value, options = {}) { } function consumeStringFlag(argv, index, flag, options = {}) { - const inlineValue = readInlineFlagValue(argv[index], flag); + const inlineValue = options.allowInline === false ? null : readInlineFlagValue(argv[index], flag); if (inlineValue !== null) { if (isMissingStringFlagValue(inlineValue, options)) { - throw new Error(`${flag} requires a value`); + failFlagParse(options.missingValueMessage ?? `${flag} requires a value`); } return { nextIndex: index, @@ -50,7 +53,7 @@ function consumeStringFlag(argv, index, flag, options = {}) { } const value = argv[index + 1]; if (isMissingStringFlagValue(value, options)) { - throw new Error(`${flag} requires a value`); + failFlagParse(options.missingValueMessage ?? `${flag} requires a value`); } return { nextIndex: index + 1, @@ -66,7 +69,7 @@ function consumeIntFlag(argv, index, flag, options = {}) { const parsed = parseIntegerFlagValue(raw.value, flag); const min = options.min ?? Number.NEGATIVE_INFINITY; if (parsed < min) { - throw new Error(`${flag} must be at least ${min}`); + failFlagParse(`${flag} must be at least ${min}`); } return { nextIndex: raw.nextIndex, @@ -83,7 +86,7 @@ function readFlagOptionValue(argv, index, flag) { const inlineValue = readInlineFlagValue(argv[index], flag); if (inlineValue !== null) { if (!inlineValue) { - throw new Error(`${flag} requires a value`); + failFlagParse(`${flag} requires a value`); } return { nextIndex: index, value: inlineValue }; } @@ -92,7 +95,7 @@ function readFlagOptionValue(argv, index, flag) { } const value = argv[index + 1]; if (!value || value.startsWith("--")) { - throw new Error(`${flag} requires a value`); + failFlagParse(`${flag} requires a value`); } return { nextIndex: index + 1, value }; } @@ -100,11 +103,11 @@ function readFlagOptionValue(argv, index, flag) { function parseIntegerFlagValue(raw, flag) { const text = String(raw).trim(); if (!/^-?\d+$/u.test(text)) { - throw new Error(`${flag} must be an integer`); + failFlagParse(`${flag} must be an integer`); } const parsed = Number(text); if (!Number.isSafeInteger(parsed)) { - throw new Error(`${flag} must be a safe integer`); + failFlagParse(`${flag} must be a safe integer`); } return parsed; } @@ -120,9 +123,9 @@ export function stringFlag(flag, key, options = {}) { return { flag, nextIndex: option.nextIndex, - repeatable: false, + repeatable: options.repeatable === true, apply(target) { - target[key] = option.value; + target[key] = options.transform ? options.transform(option.value) : option.value; }, }; }, @@ -184,7 +187,7 @@ export function intFlag(flag, key, options) { } /** Create a flag spec that assigns a fixed boolean-like value when present. */ -export function booleanFlag(flag, key, value = true) { +export function booleanFlag(flag, key, value = true, options = {}) { return { consume(argv, index) { if (argv[index] !== flag) { @@ -193,7 +196,7 @@ export function booleanFlag(flag, key, value = true) { return { flag, nextIndex: index, - repeatable: false, + repeatable: options.repeatable === true, apply(target) { target[key] = value; }, @@ -218,11 +221,14 @@ export function parseFlagArgs(argv, args, specs, options = {}) { continue; } if (typeof option.flag !== "string" || !option.flag) { - throw new Error("parseFlagArgs specs must declare a flag for consumed options"); + failFlagParse("parseFlagArgs specs must declare a flag for consumed options"); } if (option.repeatable !== true) { if (seenFlags.has(option.flag)) { - throw new Error(`${option.flag} was provided more than once`); + failFlagParse( + options.duplicateOptionMessage?.(option.flag) ?? + `${option.flag} was provided more than once`, + ); } seenFlags.add(option.flag); } @@ -239,7 +245,7 @@ export function parseFlagArgs(argv, args, specs, options = {}) { continue; } if (!options.allowUnknownOptions && arg.startsWith("-")) { - throw new Error(`Unknown option: ${arg}`); + failFlagParse(`Unknown option: ${arg}`); } } return args; diff --git a/scripts/lib/callsite-guard.mjs b/scripts/lib/callsite-guard.mjs index 68f248ffd56b..b84472958a2a 100644 --- a/scripts/lib/callsite-guard.mjs +++ b/scripts/lib/callsite-guard.mjs @@ -1,11 +1,8 @@ // Shared scanner for guard scripts that reject disallowed source callsites. import { promises as fs } from "node:fs"; import path from "node:path"; -import { - collectTypeScriptFilesFromRoots, - resolveRepoRoot, - resolveSourceRoots, -} from "./ts-guard-utils.mjs"; +import { resolveRepoRoot } from "./repo-root.mjs"; +import { collectTypeScriptFilesFromRoots, resolveSourceRoots } from "./ts-guard-utils.mjs"; /** Run a callsite guard over TypeScript roots and exit non-zero on violations. */ export async function runCallsiteGuard(params) { diff --git a/scripts/lib/extension-import-boundary-checker.mjs b/scripts/lib/extension-import-boundary-checker.mjs index 2d42ea90ccef..9c19d74b62e4 100644 --- a/scripts/lib/extension-import-boundary-checker.mjs +++ b/scripts/lib/extension-import-boundary-checker.mjs @@ -10,11 +10,8 @@ import { resolveRepoSpecifier, writeLine, } from "./guard-inventory-utils.mjs"; -import { - collectTypeScriptFilesFromRoots, - resolveRepoRoot, - resolveSourceRoots, -} from "./ts-guard-utils.mjs"; +import { resolveRepoRoot } from "./repo-root.mjs"; +import { collectTypeScriptFilesFromRoots, resolveSourceRoots } from "./ts-guard-utils.mjs"; const repoRoot = resolveRepoRoot(import.meta.url); const DEFAULT_BOUNDARY_SOURCE_MAX_BYTES = 2 * 1024 * 1024; diff --git a/scripts/lib/pairing-guard-context.mjs b/scripts/lib/pairing-guard-context.mjs index 71baf24a04fc..e9d5189811a0 100644 --- a/scripts/lib/pairing-guard-context.mjs +++ b/scripts/lib/pairing-guard-context.mjs @@ -1,6 +1,7 @@ // Builds shared repo/source-root context for pairing guard scripts. import path from "node:path"; -import { resolveRepoRoot, resolveSourceRoots } from "./ts-guard-utils.mjs"; +import { resolveRepoRoot } from "./repo-root.mjs"; +import { resolveSourceRoots } from "./ts-guard-utils.mjs"; /** Create repo root and source root helpers for pairing guard scanners. */ export function createPairingGuardContext(importMetaUrl) { diff --git a/scripts/lib/plain-gh.d.mts b/scripts/lib/plain-gh.d.mts index 5753b212e9a8..dbbaa164b132 100644 --- a/scripts/lib/plain-gh.d.mts +++ b/scripts/lib/plain-gh.d.mts @@ -4,6 +4,12 @@ import type { ExecFileSyncOptionsWithStringEncoding, } from "node:child_process"; +type ExecGhReadImpl = ( + command: string, + args: readonly string[], + options: ExecFileSyncOptions, +) => string | Uint8Array; + export function plainGhEnv(env?: NodeJS.ProcessEnv): { [key: string]: string | undefined; }; @@ -20,6 +26,26 @@ export function execPlainGh( args: readonly string[], options?: ExecFileSyncOptions, ): string | Uint8Array; +export function execGhRead( + args: readonly string[], + options: ExecFileSyncOptionsWithStringEncoding, + params?: { execFileSyncImpl?: ExecGhReadImpl }, +): string; +export function execGhRead( + args: readonly string[], + options?: ExecFileSyncOptionsWithBufferEncoding, + params?: { execFileSyncImpl?: ExecGhReadImpl }, +): Uint8Array; +export function execGhRead( + args: readonly string[], + options?: ExecFileSyncOptions, + params?: { execFileSyncImpl?: ExecGhReadImpl }, +): string | Uint8Array; +export function execGhJson( + args: readonly string[], + options?: ExecFileSyncOptions, + params?: { execFileSyncImpl?: ExecGhReadImpl }, +): unknown; export function execGhApiRead( endpoint: string, options: ExecFileSyncOptionsWithStringEncoding, diff --git a/scripts/lib/plain-gh.mjs b/scripts/lib/plain-gh.mjs index 2f26b43bedc2..5cbee81081a8 100644 --- a/scripts/lib/plain-gh.mjs +++ b/scripts/lib/plain-gh.mjs @@ -88,13 +88,22 @@ export function execPlainGh(args, options = {}) { }); } -export function execGhApiRead(endpoint, options = {}) { +export function execGhRead(args, options = {}, params = {}) { const env = plainGhEnv(options.env ?? process.env); - // Keep reads on the normal PATH shim; OPENCLAW_GH_BIN pins maintainer writes. + // Reads stay on the cache-aware PATH shim; the explicit binary is reserved for writes. delete env.OPENCLAW_GH_BIN; - return execFileSync("gh", ["api", endpoint, "--method", "GET"], { + const execFileSyncImpl = params.execFileSyncImpl ?? execFileSync; + return execFileSyncImpl("gh", args, { ...options, env, maxBuffer: options.maxBuffer ?? PLAIN_GH_MAX_BUFFER_BYTES, }); } + +export function execGhJson(args, options = {}, params = {}) { + return JSON.parse(execGhRead(args, { ...options, encoding: "utf8" }, params)); +} + +export function execGhApiRead(endpoint, options = {}) { + return execGhRead(["api", endpoint, "--method", "GET"], options); +} diff --git a/scripts/lib/repo-root.d.mts b/scripts/lib/repo-root.d.mts new file mode 100644 index 000000000000..e8342a6a2839 --- /dev/null +++ b/scripts/lib/repo-root.d.mts @@ -0,0 +1,2 @@ +/** Resolves the repository root by walking upward from the caller module. */ +export function resolveRepoRoot(importMetaUrl: string): string; diff --git a/scripts/lib/repo-root.mjs b/scripts/lib/repo-root.mjs new file mode 100644 index 000000000000..5b0677eda87b --- /dev/null +++ b/scripts/lib/repo-root.mjs @@ -0,0 +1,20 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Resolves the repository root by walking upward from the caller module. */ +export function resolveRepoRoot(importMetaUrl) { + let dir = path.dirname(fileURLToPath(importMetaUrl)); + const { root } = path.parse(dir); + while (dir !== root) { + if ( + existsSync(path.join(dir, ".git")) || + (existsSync(path.join(dir, "package.json")) && + existsSync(path.join(dir, "pnpm-workspace.yaml"))) + ) { + return dir; + } + dir = path.dirname(dir); + } + return path.resolve(path.dirname(fileURLToPath(importMetaUrl)), "..", ".."); +} diff --git a/scripts/lib/report-cli-helpers.mjs b/scripts/lib/report-cli-helpers.mjs index d934b0604df1..cda4a504c3ff 100644 --- a/scripts/lib/report-cli-helpers.mjs +++ b/scripts/lib/report-cli-helpers.mjs @@ -1,17 +1,7 @@ // Parses report CLI output arguments and writes optional artifacts. import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; - -/** - * Parses shared `--root`, `--json`, and `--markdown` flags for report scripts. - */ -function readReportOptionValue(argv, index, optionName) { - const value = argv[index + 1]; - if (value === undefined || value === "" || value.startsWith("-")) { - throw new Error(`Expected ${optionName} .`); - } - return value; -} +import { parseFlagArgs, stringFlag } from "./arg-utils.mjs"; export function parseReportCliArgs(argv) { const options = { @@ -19,37 +9,27 @@ export function parseReportCliArgs(argv) { jsonPath: null, markdownPath: null, }; - const seen = new Set(); - const setOnce = (flag, key, value) => { - if (seen.has(flag)) { - throw new Error(`${flag} was provided more than once.`); - } - seen.add(flag); - options[key] = value; - }; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === "--") { - continue; - } - if (arg === "--root") { - setOnce(arg, "rootDir", readReportOptionValue(argv, index, arg)); - index += 1; - continue; - } - if (arg === "--json") { - setOnce(arg, "jsonPath", readReportOptionValue(argv, index, arg)); - index += 1; - continue; - } - if (arg === "--markdown") { - setOnce(arg, "markdownPath", readReportOptionValue(argv, index, arg)); - index += 1; - continue; - } - throw new Error(`Unsupported argument: ${arg}`); - } - return options; + return parseFlagArgs( + argv, + options, + [ + ["--root", "rootDir"], + ["--json", "jsonPath"], + ["--markdown", "markdownPath"], + ].map(([flag, key]) => + stringFlag(flag, key, { + allowInline: false, + missingValueMessage: `Expected ${flag} .`, + rejectShortOptions: true, + }), + ), + { + duplicateOptionMessage: (flag) => `${flag} was provided more than once.`, + onUnhandledArg(arg) { + throw new Error(`Unsupported argument: ${arg}`); + }, + }, + ); } /** diff --git a/scripts/lib/ts-guard-utils.d.mts b/scripts/lib/ts-guard-utils.d.mts index 7e66bbd319a8..69cf294f449e 100644 --- a/scripts/lib/ts-guard-utils.d.mts +++ b/scripts/lib/ts-guard-utils.d.mts @@ -1,7 +1,3 @@ -/** - * Resolves the repository root by walking upward from the caller module. - */ -export function resolveRepoRoot(importMetaUrl: string): string; /** * Converts repo-relative source roots into absolute paths. */ diff --git a/scripts/lib/ts-guard-utils.mjs b/scripts/lib/ts-guard-utils.mjs index e355f0d5e160..623bd40cf9af 100644 --- a/scripts/lib/ts-guard-utils.mjs +++ b/scripts/lib/ts-guard-utils.mjs @@ -1,5 +1,5 @@ // Shared TypeScript AST and source-file helpers for guard scripts. -import { existsSync, promises as fs } from "node:fs"; +import { promises as fs } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -14,25 +14,6 @@ function getTypeScript() { const baseTestSuffixes = [".test.ts", ".test-utils.ts", ".test-harness.ts", ".e2e-harness.ts"]; -/** - * Resolves the repository root by walking upward from the caller module. - */ -export function resolveRepoRoot(importMetaUrl) { - // Walk up from the caller's directory until we find the repo root (.git). - // This handles callers at any depth (scripts/*.mjs, scripts/lib/*.mjs, etc.) - // instead of assuming a fixed number of parent traversals. - let dir = path.dirname(fileURLToPath(importMetaUrl)); - const { root } = path.parse(dir); - while (dir !== root) { - if (existsSync(path.join(dir, ".git"))) { - return dir; - } - dir = path.dirname(dir); - } - // Fallback: two levels up (original behavior). - return path.resolve(path.dirname(fileURLToPath(importMetaUrl)), "..", ".."); -} - /** * Converts repo-relative source roots into absolute paths. */ diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 7e8e4b3753da..df3e682c535d 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -4,7 +4,8 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { pathToFileURL } from "node:url"; +import { booleanFlag, parseFlagArgs } from "./lib/arg-utils.mjs"; import { deprecatedBarrelPluginSdkEntrypoints, deprecatedPublicPluginSdkEntrypoints, @@ -13,8 +14,9 @@ import { privateLocalOnlyPluginSdkEntrypoints, publicPluginSdkEntrypoints, } from "./lib/plugin-sdk-entries.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const require = createRequire(import.meta.url); let ts; @@ -30,19 +32,21 @@ Options: } function parsePluginSdkSurfaceReportArgs(argv) { - const args = { check: false, help: false }; - for (const arg of argv) { - if (arg === "--check") { - args.check = true; - continue; - } - if (arg === "--help" || arg === "-h") { - args.help = true; - continue; - } - throw new Error(`Unknown plugin SDK surface report option: ${arg}`); - } - return args; + return parseFlagArgs( + argv, + { check: false, help: false }, + [ + booleanFlag("--check", "check", true, { repeatable: true }), + booleanFlag("--help", "help", true, { repeatable: true }), + booleanFlag("-h", "help", true, { repeatable: true }), + ], + { + ignoreDoubleDash: false, + onUnhandledArg(arg) { + throw new Error(`Unknown plugin SDK surface report option: ${arg}`); + }, + }, + ); } const publicEntrypointSet = new Set(publicPluginSdkEntrypoints); const localOnlyEntrypointSet = new Set(privateLocalOnlyPluginSdkEntrypoints); diff --git a/scripts/prepare-extension-package-boundary-artifacts.mjs b/scripts/prepare-extension-package-boundary-artifacts.mjs index 573770f57b08..0f23499b6569 100644 --- a/scripts/prepare-extension-package-boundary-artifacts.mjs +++ b/scripts/prepare-extension-package-boundary-artifacts.mjs @@ -12,9 +12,9 @@ import { } from "./lib/local-heavy-check-runtime.mjs"; import { parsePositiveInt } from "./lib/numeric-options.mjs"; import { pluginSdkEntrypoints, productionPluginSdkEntrypoints } from "./lib/plugin-sdk-entries.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { resolveWindowsTaskkillPath } from "./lib/windows-taskkill.mjs"; - -const repoRoot = resolve(import.meta.dirname, ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const runTsgoScript = path.join(repoRoot, "scripts/run-tsgo.mjs"); const TYPE_INPUT_EXTENSIONS = new Set([".ts", ".tsx", ".d.ts", ".js", ".mjs", ".json"]); const VALID_MODES = new Set(["all", "package-boundary"]); diff --git a/scripts/profile-tsgo.mjs b/scripts/profile-tsgo.mjs index 1ccb5fbd1702..b88d571a5fb8 100644 --- a/scripts/profile-tsgo.mjs +++ b/scripts/profile-tsgo.mjs @@ -12,8 +12,8 @@ import { shouldAcquireLocalHeavyCheckLockForTsgo, } from "./lib/local-heavy-check-runtime.mjs"; import { createManagedCommandInvocation } from "./lib/managed-child-process.mjs"; - -const repoRoot = path.resolve(import.meta.dirname, ".."); +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const repoRoot = resolveRepoRoot(import.meta.url); const artifactRoot = path.resolve(repoRoot, ".artifacts/tsgo-profile"); const tsgoPath = resolveRepoToolBinPath("tsgo", { cwd: repoRoot }); diff --git a/scripts/publish-model-catalog.mjs b/scripts/publish-model-catalog.mjs index 4f9a63ea47fa..f770d0423d35 100644 --- a/scripts/publish-model-catalog.mjs +++ b/scripts/publish-model-catalog.mjs @@ -1,7 +1,8 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { pathToFileURL } from "node:url"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; const MODEL_CATALOG_MIN_VERSION = "2026.7.0"; export const MODEL_CATALOG_MIN_MODELS = 200; @@ -14,7 +15,7 @@ const PRICING_FETCH_TIMEOUT_MS = 60_000; const MAX_PRICING_CATALOG_BYTES = 5 * 1024 * 1024; const BUNDLE_SIZE_WARNING_BYTES = 2 * 1024 * 1024; const CLIENT_BUNDLE_LIMIT_BYTES = 4 * 1024 * 1024; -const defaultRootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const defaultRootDir = resolveRepoRoot(import.meta.url); function requireOptionValue(args, index, flag) { const value = args[index + 1]?.trim(); diff --git a/scripts/release-beta-smoke.ts b/scripts/release-beta-smoke.ts index a7d33d089975..f1d23d2e55f7 100644 --- a/scripts/release-beta-smoke.ts +++ b/scripts/release-beta-smoke.ts @@ -4,8 +4,14 @@ import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { + booleanFlag, + parseFlagArgs, + stringFlag, + stripLeadingPackageManagerSeparator, +} from "./lib/arg-utils.mjs"; -interface Options { +type Options = { beta: string; model: string; providerMode: string; @@ -13,7 +19,7 @@ interface Options { repo: string; skipParallels: boolean; skipTelegram: boolean; -} +}; export type RunOptions = { capture?: boolean; @@ -52,6 +58,8 @@ Options: export function parseArgs(argv: string[]): Options { const args = stripLeadingPackageManagerSeparator(argv); + const terminatorIndex = args.indexOf("--"); + const cliArgs = terminatorIndex === -1 ? args : args.slice(0, terminatorIndex); const options: Options = { beta: "beta", model: "openai/gpt-5.4", @@ -61,39 +69,38 @@ export function parseArgs(argv: string[]): Options { skipParallels: false, skipTelegram: false, }; - parseArgv: for (let i = 0; i < args.length; i++) { - const arg = args[i]; - switch (arg) { - case "--": - break parseArgv; - case "--beta": - options.beta = requireValue(args, ++i, arg); - break; - case "--model": - options.model = requireValue(args, ++i, arg); - break; - case "--provider-mode": - options.providerMode = requireValue(args, ++i, arg); - break; - case "--ref": - options.ref = requireValue(args, ++i, arg); - break; - case "--repo": - options.repo = requireValue(args, ++i, arg); - break; - case "--skip-parallels": - options.skipParallels = true; - break; - case "--skip-telegram": - options.skipTelegram = true; - break; - case "-h": - case "--help": - process.stdout.write(usage()); - process.exit(0); - default: + const helpIndex = cliArgs.findIndex((arg) => arg === "-h" || arg === "--help"); + parseFlagArgs( + helpIndex === -1 ? cliArgs : cliArgs.slice(0, helpIndex), + options, + [ + ...( + [ + ["--beta", "beta"], + ["--model", "model"], + ["--provider-mode", "providerMode"], + ["--ref", "ref"], + ["--repo", "repo"], + ] as const + ).map(([flag, key]) => + stringFlag(flag, key, { + allowInline: false, + rejectShortOptions: true, + repeatable: true, + }), + ), + booleanFlag("--skip-parallels", "skipParallels", true, { repeatable: true }), + booleanFlag("--skip-telegram", "skipTelegram", true, { repeatable: true }), + ], + { + onUnhandledArg(arg) { throw new Error(`unknown option: ${arg}`); - } + }, + }, + ); + if (helpIndex !== -1) { + process.stdout.write(usage()); + process.exit(0); } if (options.skipParallels && options.skipTelegram) { throw new Error("--skip-parallels and --skip-telegram cannot be used together"); @@ -101,18 +108,6 @@ export function parseArgs(argv: string[]): Options { return options; } -function stripLeadingPackageManagerSeparator(argv: string[]): string[] { - return argv[0] === "--" ? argv.slice(1) : argv; -} - -function requireValue(argv: string[], index: number, flag: string): string { - const value = argv[index]; - if (!value || value.startsWith("-")) { - throw new Error(`${flag} requires a value`); - } - return value; -} - const CAPTURE_MAX_BUFFER_BYTES = 32 * 1024 * 1024; const DEFAULT_COMMAND_TIMEOUT_MS = readPositiveInt( process.env.OPENCLAW_RELEASE_BETA_SMOKE_COMMAND_MS, diff --git a/scripts/release-candidate-checklist.mjs b/scripts/release-candidate-checklist.mjs index 9e38ad071ed2..a4d19d1f764e 100644 --- a/scripts/release-candidate-checklist.mjs +++ b/scripts/release-candidate-checklist.mjs @@ -17,7 +17,13 @@ import { tmpdir } from "node:os"; import { basename, dirname, join, resolve as resolvePath } from "node:path"; import { fileURLToPath } from "node:url"; import { isDeepStrictEqual } from "node:util"; -import { stripLeadingPackageManagerSeparator } from "./lib/arg-utils.mjs"; +import { + booleanFlag, + parseFlagArgs, + stringFlag, + stringListFlag, + stripLeadingPackageManagerSeparator, +} from "./lib/arg-utils.mjs"; import { readBoundedResponseText } from "./lib/bounded-response.mjs"; import { dedicatedSectionVersionForTag, @@ -109,14 +115,6 @@ Options: `; } -function requireValue(argv, index, flag) { - const value = argv[index]; - if (!value || value.startsWith("-")) { - throw new Error(`${flag} requires a value`); - } - return value; -} - export function releaseBranchForTag(tag) { if (tag.includes("-alpha.")) { return ""; @@ -130,6 +128,8 @@ export function releaseBranchForTag(tag) { */ export function parseArgs(argv) { const args = stripLeadingPackageManagerSeparator(argv); + const terminatorIndex = args.indexOf("--"); + const cliArgs = terminatorIndex === -1 ? args : args.slice(0, terminatorIndex); const options = { repo: DEFAULT_REPO, provider: DEFAULT_PROVIDER, @@ -154,86 +154,49 @@ export function parseArgs(argv) { windowsNodeInstallerDigests: "", outputDir: "", }; - const seen = new Set(); - const setOnce = (flag, key, value) => { - if (seen.has(flag)) { - throw new Error(`${flag} was provided more than once`); - } - seen.add(flag); - options[key] = value; - }; - parseArgv: for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - switch (arg) { - case "--": - break parseArgv; - case "--tag": - setOnce(arg, "tag", requireValue(args, ++index, arg)); - break; - case "--target-sha": - setOnce(arg, "targetSha", requireValue(args, ++index, arg)); - break; - case "--workflow-ref": - setOnce(arg, "workflowRef", requireValue(args, ++index, arg)); - break; - case "--repo": - setOnce(arg, "repo", requireValue(args, ++index, arg)); - break; - case "--full-release-run": - setOnce(arg, "fullReleaseRunId", requireValue(args, ++index, arg)); - break; - case "--npm-preflight-run": - setOnce(arg, "npmPreflightRunId", requireValue(args, ++index, arg)); - break; - case "--windows-node-tag": - setOnce(arg, "windowsNodeTag", requireValue(args, ++index, arg)); - break; - case "--skip-dispatch": - setOnce(arg, "skipDispatch", true); - break; - case "--skip-local-generated-check": - setOnce(arg, "skipLocalGeneratedCheck", true); - break; - case "--skip-parallels": - setOnce(arg, "skipParallels", true); - break; - case "--parallels-registry-package-artifact": - options.parallelsRegistryPackageArtifactDirs.push(requireValue(args, ++index, arg)); - break; - case "--skip-telegram": - setOnce(arg, "skipTelegram", true); - break; - case "--telegram-provider-mode": - setOnce(arg, "telegramProviderMode", requireValue(args, ++index, arg)); - break; - case "--provider": - setOnce(arg, "provider", requireValue(args, ++index, arg)); - break; - case "--mode": - setOnce(arg, "mode", requireValue(args, ++index, arg)); - break; - case "--release-profile": - setOnce(arg, "releaseProfile", requireValue(args, ++index, arg)); - break; - case "--npm-dist-tag": - setOnce(arg, "npmDistTag", requireValue(args, ++index, arg)); - break; - case "--plugin-publish-scope": - setOnce(arg, "pluginPublishScope", requireValue(args, ++index, arg)); - break; - case "--plugins": - setOnce(arg, "plugins", requireValue(args, ++index, arg)); - break; - case "--output-dir": - setOnce(arg, "outputDir", requireValue(args, ++index, arg)); - break; - case "-h": - case "--help": - process.stdout.write(usage()); - process.exit(0); - default: + const helpIndex = cliArgs.findIndex((arg) => arg === "-h" || arg === "--help"); + parseFlagArgs( + helpIndex === -1 ? cliArgs : cliArgs.slice(0, helpIndex), + options, + [ + ...[ + ["--tag", "tag"], + ["--target-sha", "targetSha"], + ["--workflow-ref", "workflowRef"], + ["--repo", "repo"], + ["--full-release-run", "fullReleaseRunId"], + ["--npm-preflight-run", "npmPreflightRunId"], + ["--windows-node-tag", "windowsNodeTag"], + ["--telegram-provider-mode", "telegramProviderMode"], + ["--provider", "provider"], + ["--mode", "mode"], + ["--release-profile", "releaseProfile"], + ["--npm-dist-tag", "npmDistTag"], + ["--plugin-publish-scope", "pluginPublishScope"], + ["--plugins", "plugins"], + ["--output-dir", "outputDir"], + ].map(([flag, key]) => + stringFlag(flag, key, { allowInline: false, rejectShortOptions: true }), + ), + stringListFlag( + "--parallels-registry-package-artifact", + "parallelsRegistryPackageArtifactDirs", + { allowInline: false, rejectShortOptions: true }, + ), + booleanFlag("--skip-dispatch", "skipDispatch"), + booleanFlag("--skip-local-generated-check", "skipLocalGeneratedCheck"), + booleanFlag("--skip-parallels", "skipParallels"), + booleanFlag("--skip-telegram", "skipTelegram"), + ], + { + onUnhandledArg(arg) { throw new Error(`unknown option: ${arg}`); - } + }, + }, + ); + if (helpIndex !== -1) { + process.stdout.write(usage()); + process.exit(0); } if (!options.tag) { throw new Error("--tag is required"); diff --git a/scripts/resolve-openclaw-package-candidate.mjs b/scripts/resolve-openclaw-package-candidate.mjs index 1d71325814f7..cbd1e91f54f2 100644 --- a/scripts/resolve-openclaw-package-candidate.mjs +++ b/scripts/resolve-openclaw-package-candidate.mjs @@ -13,11 +13,13 @@ import os from "node:os"; import path from "node:path"; import { pipeline } from "node:stream/promises"; import { fileURLToPath } from "node:url"; +import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs"; import { resolveNpmJsonEntries } from "./lib/npm-json-output.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { resolveWindowsTaskkillPath } from "./lib/windows-taskkill.mjs"; import { resolveNpmRunner } from "./npm-runner.mjs"; -const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const ROOT_DIR = resolveRepoRoot(import.meta.url); const DEFAULT_OUTPUT_NAME = "openclaw-current.tgz"; const PACKAGE_URL_DOWNLOAD_TIMEOUT_MS = 60_000; const PACKAGE_URL_MAX_BYTES = 250 * 1024 * 1024; @@ -98,57 +100,49 @@ export function parseArgs(argv) { trustedSourceId: "", trustedSourcePolicy: TRUSTED_PACKAGE_SOURCE_POLICY, }; - const seen = new Set(); - const setOnce = (flag, key, value) => { - if (seen.has(flag)) { - throw new Error(`${flag} was provided more than once`); - } - seen.add(flag); - options[key] = value; - }; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - const readValue = (name, readOptions = {}) => { - const value = argv[(index += 1)]; - if ( - value === undefined || - (!readOptions.allowEmpty && value === "") || - value.startsWith("-") - ) { - throw new Error(`${name} requires a value`); - } - return value; - }; - if (arg === "--artifact-dir") { - setOnce(arg, "artifactDir", readValue(arg)); - } else if (arg === "--github-output") { - setOnce(arg, "githubOutput", readValue(arg)); - } else if (arg === "--metadata") { - setOnce(arg, "metadata", readValue(arg)); - } else if (arg === "--output-dir") { - setOnce(arg, "outputDir", readValue(arg)); - } else if (arg === "--output-name") { - setOnce(arg, "outputName", readValue(arg)); - } else if (arg === "--package-sha256") { - setOnce(arg, "packageSha256", readValue(arg, { allowEmpty: true }).toLowerCase()); - } else if (arg === "--package-ref") { - setOnce(arg, "packageRef", readValue(arg, { allowEmpty: true })); - } else if (arg === "--package-spec") { - setOnce(arg, "packageSpec", readValue(arg, { allowEmpty: true })); - } else if (arg === "--package-url") { - setOnce(arg, "packageUrl", readValue(arg, { allowEmpty: true })); - } else if (arg === "--source") { - setOnce(arg, "source", readValue(arg)); - } else if (arg === "--trusted-source-id") { - setOnce(arg, "trustedSourceId", readValue(arg, { allowEmpty: true })); - } else if (arg === "--trusted-source-policy") { - setOnce(arg, "trustedSourcePolicy", readValue(arg)); - } else if (arg === "--help" || arg === "-h") { - options.help = true; - } else { - throw new Error(`unknown argument: ${arg}`); - } - } + parseFlagArgs( + argv, + options, + [ + ...[ + ["--artifact-dir", "artifactDir"], + ["--github-output", "githubOutput"], + ["--metadata", "metadata"], + ["--output-dir", "outputDir"], + ["--output-name", "outputName"], + ["--source", "source"], + ["--trusted-source-policy", "trustedSourcePolicy"], + ].map(([flag, key]) => + stringFlag(flag, key, { allowInline: false, rejectShortOptions: true }), + ), + ...[ + ["--package-ref", "packageRef"], + ["--package-spec", "packageSpec"], + ["--package-url", "packageUrl"], + ["--trusted-source-id", "trustedSourceId"], + ].map(([flag, key]) => + stringFlag(flag, key, { + allowEmpty: true, + allowInline: false, + rejectShortOptions: true, + }), + ), + stringFlag("--package-sha256", "packageSha256", { + allowEmpty: true, + allowInline: false, + rejectShortOptions: true, + transform: (value) => value.toLowerCase(), + }), + booleanFlag("--help", "help", true, { repeatable: true }), + booleanFlag("-h", "help", true, { repeatable: true }), + ], + { + ignoreDoubleDash: false, + onUnhandledArg(arg) { + throw new Error(`unknown argument: ${arg}`); + }, + }, + ); validateOutputName(options.outputName); return options; } diff --git a/scripts/run-android-gradle.mjs b/scripts/run-android-gradle.mjs index ecaf841fb535..4d4c89a016d9 100644 --- a/scripts/run-android-gradle.mjs +++ b/scripts/run-android-gradle.mjs @@ -4,9 +4,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; -const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(scriptDir, ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const androidDir = path.join(repoRoot, "apps", "android"); const isMain = process.argv[1] ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) diff --git a/scripts/run-vitest.mjs b/scripts/run-vitest.mjs index b1d1f6a0e30c..4f8b6d1abd0c 100644 --- a/scripts/run-vitest.mjs +++ b/scripts/run-vitest.mjs @@ -4,13 +4,13 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { embeddedAgentVitestProjectOwners } from "../test/vitest/vitest.agents-paths.mjs"; import { toolingIsolatedTestFiles } from "../test/vitest/vitest.tooling-isolated-paths.mjs"; import { isUiTestTarget } from "../test/vitest/vitest.ui-paths.mjs"; import { boundaryTestFiles } from "../test/vitest/vitest.unit-paths.mjs"; import { runWithFailedTrailer, writeFailedTrailer } from "./lib/failed-trailer.mjs"; import { signalExitCode } from "./lib/managed-child-process.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { resolveLocalVitestEnv } from "./lib/vitest-local-scheduling.mjs"; import { spawnPnpmRunner } from "./pnpm-runner.mjs"; import { @@ -127,7 +127,7 @@ const VITEST_DOTTED_OPTIONS_WITH_VALUE_PREFIXES = [ "--typecheck.", ]; const require = createRequire(import.meta.url); -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const testProjectsRunnerPath = path.join(repoRoot, "scripts", "test-projects.mjs"); function isTruthyEnvValue(value) { diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index 70c93d741feb..a9ce1dafd58b 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -4,11 +4,12 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; import { performance } from "node:perf_hooks"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { pathToFileURL } from "node:url"; import { buildSync } from "esbuild"; import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mjs"; import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; import { escapeRegExp } from "./lib/regexp.mjs"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { copyStaticExtensionAssets, copyStaticExtensionAssetsToRuntimeOverlay, @@ -21,7 +22,7 @@ import { writeOfficialChannelCatalog } from "./write-official-channel-catalog.mj /** @internal Shared repository-script contract. */ export { listStaticExtensionAssetOutputs }; -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const ROOT = resolveRepoRoot(import.meta.url); const ROOT_RUNTIME_ALIAS_PATTERN = /^(?.+\.(?:runtime|contract))-[A-Za-z0-9_-]+\.js$/u; const ROOT_STABLE_RUNTIME_ALIAS_PATTERN = /^.+\.(?:runtime|contract)\.js$/u; const ROOT_RUNTIME_IMPORT_SPECIFIER_PATTERN = diff --git a/scripts/sync-native-a2ui.mjs b/scripts/sync-native-a2ui.mjs index 097cf4ea444f..71c1a03ba077 100644 --- a/scripts/sync-native-a2ui.mjs +++ b/scripts/sync-native-a2ui.mjs @@ -5,9 +5,9 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +import { pathToFileURL } from "node:url"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +const rootDir = resolveRepoRoot(import.meta.url); const REQUIRED_RESOURCE_FILES = ["a2ui.bundle.js", "index.html"]; export function getNativeA2uiResourcePaths(repoRoot = rootDir) { diff --git a/scripts/test-built-plugin-singleton.mjs b/scripts/test-built-plugin-singleton.mjs index 55f004613378..8eee009a43f7 100644 --- a/scripts/test-built-plugin-singleton.mjs +++ b/scripts/test-built-plugin-singleton.mjs @@ -3,13 +3,14 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { pathToFileURL } from "node:url"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; import { installProcessWarningFilter } from "./process-warning-filter.mjs"; import { stageBundledPluginRuntime } from "./stage-bundled-plugin-runtime.mjs"; installProcessWarningFilter(); -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolveRepoRoot(import.meta.url); const smokeEntryPath = path.join(repoRoot, "dist", "plugins", "build-smoke-entry.js"); assert.ok(fs.existsSync(smokeEntryPath), `missing build output: ${smokeEntryPath}`); diff --git a/scripts/verify-pr-hosted-gates.mjs b/scripts/verify-pr-hosted-gates.mjs index 35fad2404d81..bb223193f68c 100644 --- a/scripts/verify-pr-hosted-gates.mjs +++ b/scripts/verify-pr-hosted-gates.mjs @@ -2,6 +2,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; +import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mjs"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; import { execGhApiRead, plainGhEnv } from "./lib/plain-gh.mjs"; @@ -31,14 +32,6 @@ const MAX_CI_REUSE_CANDIDATES = 5; const CI_REUSE_RUN_LIST_LIMIT = 50; const GIT_MAX_BUFFER_BYTES = 64 * 1024 * 1024; -function readOptionValue(argv, index, optionName) { - const value = argv[index + 1]; - if (!value || value.startsWith("-")) { - throw new Error(`Expected ${optionName} .`); - } - return value; -} - export function parseArgs(argv) { const args = { repo: "", @@ -48,49 +41,44 @@ export function parseArgs(argv) { output: "", changelogOnly: false, }; - const seen = new Set(); - const setOnce = (flag, key, value) => { - if (seen.has(flag)) { - throw new Error(`${flag} was provided more than once.`); - } - seen.add(flag); - args[key] = value; - }; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - switch (arg) { - case "--repo": - setOnce(arg, "repo", readOptionValue(argv, index, arg)); - index += 1; - break; - case "--sha": - setOnce(arg, "sha", readOptionValue(argv, index, arg)); - index += 1; - break; - case "--pr": { - const value = Number(readOptionValue(argv, index, arg)); - if (!Number.isSafeInteger(value) || value <= 0) { - throw new Error("Expected --pr ."); - } - setOnce(arg, "pr", value); - index += 1; - break; - } - case "--recent-sha": - setOnce(arg, "recentSha", readOptionValue(argv, index, arg)); - index += 1; - break; - case "--output": - setOnce(arg, "output", readOptionValue(argv, index, arg)); - index += 1; - break; - case "--changelog-only": - setOnce(arg, "changelogOnly", true); - break; - default: + parseFlagArgs( + argv, + args, + [ + ...[ + ["--repo", "repo"], + ["--sha", "sha"], + ["--recent-sha", "recentSha"], + ["--output", "output"], + ].map(([flag, key]) => + stringFlag(flag, key, { + allowInline: false, + missingValueMessage: `Expected ${flag} .`, + rejectShortOptions: true, + }), + ), + stringFlag("--pr", "pr", { + allowInline: false, + missingValueMessage: "Expected --pr .", + rejectShortOptions: true, + transform(value) { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error("Expected --pr ."); + } + return parsed; + }, + }), + booleanFlag("--changelog-only", "changelogOnly"), + ], + { + duplicateOptionMessage: (flag) => `${flag} was provided more than once.`, + ignoreDoubleDash: false, + onUnhandledArg(arg) { throw new Error(`Unknown option: ${arg}`); - } - } + }, + }, + ); if (!args.repo || !args.sha || !args.pr || !args.output) { throw new Error( "Usage: node scripts/verify-pr-hosted-gates.mjs --repo --sha --pr [--recent-sha ] --output ", diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 4b61524b7099..20e5b151e499 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -1,5 +1,6 @@ // Runs the broad verification graph used by Crabbox/Testbox: check then test. import { performance } from "node:perf_hooks"; +import { booleanFlag, parseFlagArgs } from "./lib/arg-utils.mjs"; import { formatMs, printTimingSummary } from "./lib/check-timing-summary.mjs"; import { runManagedCommand } from "./lib/managed-child-process.mjs"; @@ -26,15 +27,20 @@ function usage() { * Parses verify wrapper CLI args. */ function parseVerifyArgs(argv) { - const args = { help: false }; - for (const arg of argv) { - if (arg === "--help" || arg === "-h") { - args.help = true; - } else { - throw new Error(`unknown argument: ${arg}\n\n${usage()}`); - } - } - return args; + return parseFlagArgs( + argv, + { help: false }, + [ + booleanFlag("--help", "help", true, { repeatable: true }), + booleanFlag("-h", "help", true, { repeatable: true }), + ], + { + ignoreDoubleDash: false, + onUnhandledArg(arg) { + throw new Error(`unknown argument: ${arg}\n\n${usage()}`); + }, + }, + ); } async function runStage(stage) { diff --git a/scripts/watch-pr-ci.mjs b/scripts/watch-pr-ci.mjs index 598ca6ecd999..36ac82d90635 100644 --- a/scripts/watch-pr-ci.mjs +++ b/scripts/watch-pr-ci.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node -import { execFileSync } from "node:child_process"; import { setTimeout as sleep } from "node:timers/promises"; import { parseArgs as parseNodeArgs } from "node:util"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; +import { execGhJson } from "./lib/plain-gh.mjs"; const USAGE = "Usage: node scripts/watch-pr-ci.mjs [--repo owner/repo] [--after run-id] [--attach-timeout 900] [--timeout 3600] [--interval 120]"; @@ -15,6 +15,10 @@ const FAILURE_CONCLUSIONS = new Set([ "TIMED_OUT", ]); const ROLLUP_QUERY = `query($owner:String!,$name:String!,$pr:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$pr){state mergeable headRefOid statusCheckRollup{state contexts(first:100,after:$cursor){totalCount pageInfo{hasNextPage endCursor} nodes{kind:__typename ... on CheckRun{name status conclusion databaseId checkSuite{workflowRun{databaseId workflow{databaseId}}}} ... on StatusContext{context state}}}}}}}`; +const GH_READ_OPTIONS = { + stdio: ["ignore", "pipe", "pipe"], + timeout: 60_000, +}; // Adapted from Node's MIT-licensed util.stripVTControlCharacters implementation. const ANSI_ESCAPE_SEQUENCE = new RegExp( "[\\u001B\\u009B][[\\]()#;?]*" + @@ -207,18 +211,11 @@ export function classifyRollup(rollup) { return { verdict: "PENDING", pendingCount, failingNames: [], supersededCount }; } -function ghJson(...args) { - return JSON.parse( - execFileSync("gh", args, { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - timeout: 60_000, - }), - ); -} - const readPr = (pr, repo) => - ghJson(...`pr view ${pr} --repo ${repo} --json state,mergeable,headRefOid`.split(" ")); + execGhJson( + `pr view ${pr} --repo ${repo} --json state,mergeable,headRefOid`.split(" "), + GH_READ_OPTIONS, + ); export const buildFindRunArgs = (repo, sha) => [ "run", "list", @@ -237,9 +234,13 @@ export const buildFindRunArgs = (repo, sha) => [ ]; export const selectRunAfter = (runs, after) => runs.find((run) => after === undefined || run.databaseId > after); -const findRun = (repo, sha, after) => selectRunAfter(ghJson(...buildFindRunArgs(repo, sha)), after); +const findRun = (repo, sha, after) => + selectRunAfter(execGhJson(buildFindRunArgs(repo, sha), GH_READ_OPTIONS), after); const readRun = (repo, runId) => - ghJson(...`run view ${runId} --repo ${repo} --json status,conclusion`.split(" ")); + execGhJson( + `run view ${runId} --repo ${repo} --json status,conclusion`.split(" "), + GH_READ_OPTIONS, + ); export function classifyRunAttachment(runId, run, after) { if (run.conclusion === "skipped") { @@ -317,7 +318,7 @@ function readRollup(pr, repo) { if (cursor !== null) { queryArgs.push("-f", `cursor=${cursor}`); } - return ghJson(...queryArgs).data?.repository?.pullRequest; + return execGhJson(queryArgs, GH_READ_OPTIONS).data?.repository?.pullRequest; }); } diff --git a/test/scripts/arg-utils.test.ts b/test/scripts/arg-utils.test.ts index f2480af0333d..07b873032c47 100644 --- a/test/scripts/arg-utils.test.ts +++ b/test/scripts/arg-utils.test.ts @@ -38,6 +38,31 @@ describe("scripts/lib/arg-utils parseFlagArgs", () => { expect(parsed.match).toEqual(["alpha", "beta"]); }); + it("supports split-only, empty, transformed, and last-value-wins string contracts", () => { + expect(() => + parseFlagArgs(["--value=inline"], { value: "" }, [ + stringFlag("--value", "value", { allowInline: false }), + ]), + ).toThrow("Unknown option: --value=inline"); + expect( + parseFlagArgs(["--value", "", "--value", "SECOND"], { value: "" }, [ + stringFlag("--value", "value", { + allowEmpty: true, + repeatable: true, + transform: (value) => value.toLowerCase(), + }), + ]).value, + ).toBe("second"); + }); + + it("supports idempotent boolean flags", () => { + expect( + parseFlagArgs(["--verbose", "--verbose"], { verbose: false }, [ + booleanFlag("--verbose", "verbose", true, { repeatable: true }), + ]).verbose, + ).toBe(true); + }); + it("rejects duplicate single-value flags", () => { expect(() => parseFlagArgs(["--label", "first", "--label=second"], { label: "" }, [ diff --git a/test/scripts/check.test.ts b/test/scripts/check.test.ts index 0c6c82819bcb..e942ac2d851c 100644 --- a/test/scripts/check.test.ts +++ b/test/scripts/check.test.ts @@ -21,13 +21,15 @@ describe("scripts/check", () => { }); it("rejects unknown args before running check stages", () => { - const result = runCheck("--bogus"); + for (const args of [["--bogus"], ["bogus", "--help"]]) { + const result = runCheck(...args); - expect(result.status).toBe(2); - expect(result.stdout).toBe(""); - expect(result.stderr).toContain("unknown argument: --bogus"); - expect(result.stderr).toContain("Usage: node scripts/check.mjs"); - expect(result.stderr).not.toContain("[check]"); + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(`unknown argument: ${args[0]}`); + expect(result.stderr).toContain("Usage: node scripts/check.mjs"); + expect(result.stderr).not.toContain("[check]"); + } }); it("runs pnpm commands through the managed child runner", async () => { diff --git a/test/scripts/full-release-validation-at-sha.test.ts b/test/scripts/full-release-validation-at-sha.test.ts index 31af38cecab2..26049aea147c 100644 --- a/test/scripts/full-release-validation-at-sha.test.ts +++ b/test/scripts/full-release-validation-at-sha.test.ts @@ -9,7 +9,6 @@ import { releaseEvidenceVerificationArgs, releaseEvidenceVerifierPath, resolveRemoteTargetRefSha, - runGhRead, shouldDeleteTemporaryWorkflowRef, } from "../../scripts/full-release-validation-at-sha.mjs"; @@ -163,30 +162,9 @@ describe("full-release-validation-at-sha", () => { }); it("bounds GitHub reads without applying a timeout to workflow dispatch", () => { - const calls: unknown[][] = []; - expect( - runGhRead(["api", "repos/openclaw/openclaw/actions/runs/123"], { - execFileSyncImpl: (...args: unknown[]) => { - calls.push(args); - return " result "; - }, - }), - ).toBe("result"); - expect(calls).toEqual([ - [ - "gh", - ["api", "repos/openclaw/openclaw/actions/runs/123"], - expect.objectContaining({ - killSignal: "SIGKILL", - timeout: 60_000, - }), - ], - ]); - const source = readFileSync("scripts/full-release-validation-at-sha.mjs", "utf8"); - expect(source).toContain( - 'runGhRead(["api", `repos/openclaw/openclaw/actions/runs/${parentRunId}`])', - ); + expect(source).toContain("timeout: GH_READ_TIMEOUT_MS"); + expect(source.match(/GH_READ_OPTIONS/gu)).toHaveLength(3); expect(source).toContain('const dispatchOutput = run("gh", dispatchArgs'); }); diff --git a/test/scripts/generate-dependency-release-evidence.test.ts b/test/scripts/generate-dependency-release-evidence.test.ts index 163b4f183df4..f8ec23c1ae3c 100644 --- a/test/scripts/generate-dependency-release-evidence.test.ts +++ b/test/scripts/generate-dependency-release-evidence.test.ts @@ -206,12 +206,14 @@ describe("generate-dependency-release-evidence", () => { }); it("reports CLI argument errors without a Node stack trace", () => { - const result = runCli("--wat"); + for (const args of [["--wat"], ["wat", "--help"]]) { + const result = runCli(...args); - expect(result.status).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr.trim()).toBe("Unsupported argument: --wat"); - expectNoNodeStack(result.stderr); + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr.trim()).toBe(`Unsupported argument: ${args[0]}`); + expectNoNodeStack(result.stderr); + } }); it("falls back to fetching tags when local previous-release resolution misses", () => { diff --git a/test/scripts/plain-gh.test.ts b/test/scripts/plain-gh.test.ts index 5767a43c5dc4..cdce4cd52462 100644 --- a/test/scripts/plain-gh.test.ts +++ b/test/scripts/plain-gh.test.ts @@ -6,6 +6,8 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { execGhApiRead, + execGhJson, + execGhRead, execPlainGh, plainGhEnv, PLAIN_GH_SYSTEM_CANDIDATES, @@ -114,6 +116,59 @@ describe("plain gh helpers", () => { expect(output).toContain("OPENCLAW_GH_BIN_SET="); }); + it("shares bounded PATH-shim reads and JSON parsing", () => { + const calls: unknown[][] = []; + const execFileSyncImpl = (...args: unknown[]) => { + calls.push(args); + return '{"ok":true}'; + }; + + expect( + execGhJson( + ["api", "repos/openclaw/openclaw"], + { + killSignal: "SIGKILL", + stdio: ["ignore", "pipe", "inherit"], + timeout: 60_000, + }, + { execFileSyncImpl }, + ), + ).toEqual({ ok: true }); + expect(calls).toEqual([ + [ + "gh", + ["api", "repos/openclaw/openclaw"], + expect.objectContaining({ + encoding: "utf8", + killSignal: "SIGKILL", + maxBuffer: 32 * 1024 * 1024, + stdio: ["ignore", "pipe", "inherit"], + timeout: 60_000, + }), + ], + ]); + expect( + execGhRead( + ["api", "rate_limit"], + { encoding: "utf8" }, + { execFileSyncImpl: () => " result " }, + ), + ).toBe(" result "); + + const failure = new Error("gh read failed"); + expect(() => + execGhRead( + ["api", "rate_limit"], + {}, + { + execFileSyncImpl: () => { + throw failure; + }, + }, + ), + ).toThrow(failure); + }); + it("runs the shell helper with color disabled", () => { const ghPath = makeFakeGh(); const outputPath = path.join(path.dirname(path.dirname(ghPath)), "output.txt"); diff --git a/test/scripts/plugin-npm-extended-stable-workflow.test.ts b/test/scripts/plugin-npm-extended-stable-workflow.test.ts index 23e9d4694a27..7a01cecb29a6 100644 --- a/test/scripts/plugin-npm-extended-stable-workflow.test.ts +++ b/test/scripts/plugin-npm-extended-stable-workflow.test.ts @@ -107,6 +107,11 @@ describe("plugin npm extended-stable workflow", () => { it("overlays the complete trusted packaging helper dependency set", () => { const parsed = workflow(); + const lockGenerator = readFileSync("scripts/generate-npm-package-lock.mjs", "utf8"); + expect(lockGenerator).toContain( + 'path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")', + ); + expect(lockGenerator).not.toContain("./lib/repo-root.mjs"); const preflightCheckout = step( parsed.jobs?.preview_plugin_pack, "Checkout trusted packaging helper", diff --git a/test/scripts/plugin-sdk-surface-report.test.ts b/test/scripts/plugin-sdk-surface-report.test.ts index 0def49d33993..8bfc57cb8444 100644 --- a/test/scripts/plugin-sdk-surface-report.test.ts +++ b/test/scripts/plugin-sdk-surface-report.test.ts @@ -59,19 +59,21 @@ describe("plugin SDK surface report", () => { }); it("rejects unknown CLI options before collecting SDK stats", () => { - const result = spawnSync( - process.execPath, - ["scripts/plugin-sdk-surface-report.mjs", "--chekc"], - { - cwd: process.cwd(), - encoding: "utf8", - }, - ); + for (const args of [["--chekc"], ["chekc", "--help"]]) { + const result = spawnSync( + process.execPath, + ["scripts/plugin-sdk-surface-report.mjs", ...args], + { + cwd: process.cwd(), + encoding: "utf8", + }, + ); - expect(result.status).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr.trim()).toBe("Unknown plugin SDK surface report option: --chekc"); - expect(result.stderr).not.toContain("at "); + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr.trim()).toBe(`Unknown plugin SDK surface report option: ${args[0]}`); + expect(result.stderr).not.toContain("at "); + } }); it("prints help before collecting SDK stats", () => { diff --git a/test/scripts/test-force.test.ts b/test/scripts/test-force.test.ts index 140bb2c9d2b9..d0cb4d4ba49e 100644 --- a/test/scripts/test-force.test.ts +++ b/test/scripts/test-force.test.ts @@ -4,7 +4,7 @@ import { testForceTesting } from "../../scripts/test-force.js"; describe("scripts/test-force.ts", () => { it("prints help without clearing ports or running tests", () => { - const args = testForceTesting.parseArgs(["--help"]); + const args = testForceTesting.parseArgs(["--help", "--bogus"]); expect(args).toEqual({ help: true }); expect(testForceTesting.usage()).toContain("Usage: node --import tsx scripts/test-force.ts"); @@ -16,5 +16,11 @@ describe("scripts/test-force.ts", () => { expect(() => testForceTesting.parseArgs(["--bogus"])).toThrow( /unknown argument: --bogus[\s\S]*Usage: node --import tsx scripts\/test-force\.ts/u, ); + expect(() => testForceTesting.parseArgs(["bogus"])).toThrow( + /unknown argument: bogus[\s\S]*Usage: node --import tsx scripts\/test-force\.ts/u, + ); + expect(() => testForceTesting.parseArgs(["bogus", "--help"])).toThrow( + /unknown argument: bogus[\s\S]*Usage: node --import tsx scripts\/test-force\.ts/u, + ); }); }); diff --git a/test/scripts/test-projects.test.ts b/test/scripts/test-projects.test.ts index 7867231d50f2..51315d9c9134 100644 --- a/test/scripts/test-projects.test.ts +++ b/test/scripts/test-projects.test.ts @@ -1931,6 +1931,7 @@ describe("scripts/test-projects changed-target routing", () => { "scripts/lib/ts-topology/analyze.ts": ["test/scripts/ts-topology.test.ts"], "scripts/lib/ts-topology/reports.ts": ["test/scripts/ts-topology.test.ts"], "scripts/lib/ts-topology/scope.ts": ["test/scripts/ts-topology.test.ts"], + "scripts/lib/repo-root.mjs": ["test/scripts/ts-guard-utils.test.ts"], "scripts/lib/ts-guard-utils.mjs": ["test/scripts/ts-guard-utils.test.ts"], "scripts/lib/tsgo-sparse-guard.mjs": [ "test/scripts/run-tsgo.test.ts", diff --git a/test/scripts/ts-guard-utils.test.ts b/test/scripts/ts-guard-utils.test.ts index 3a50e99b9721..c7849aac6fb6 100644 --- a/test/scripts/ts-guard-utils.test.ts +++ b/test/scripts/ts-guard-utils.test.ts @@ -1,9 +1,10 @@ // Ts Guard Utils tests cover ts guard utils script behavior. -import { existsSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; -import { resolveRepoRoot } from "../../scripts/lib/ts-guard-utils.mjs"; +import { resolveRepoRoot } from "../../scripts/lib/repo-root.mjs"; /** * Regression tests for resolveRepoRoot(). @@ -49,4 +50,19 @@ describe("resolveRepoRoot", () => { expect(fromLib).toBe(fromScripts); expect(fromScripts).toBe(fromExtension); }); + + it("resolves an unpacked workspace without git metadata", () => { + const root = mkdtempSync(path.join(tmpdir(), "openclaw-repo-root-")); + try { + mkdirSync(path.join(root, "scripts", "nested"), { recursive: true }); + writeFileSync(path.join(root, "package.json"), '{"name":"openclaw"}\n'); + writeFileSync(path.join(root, "pnpm-workspace.yaml"), "packages: []\n"); + + expect( + resolveRepoRoot(pathToFileURL(path.join(root, "scripts", "nested", "tool.mjs")).href), + ).toBe(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/test/scripts/verify.test.ts b/test/scripts/verify.test.ts index 15faa7f97f7f..03a024f1e210 100644 --- a/test/scripts/verify.test.ts +++ b/test/scripts/verify.test.ts @@ -21,13 +21,15 @@ describe("scripts/verify", () => { }); it("rejects unknown args before running verify stages", () => { - const result = runVerify("--bogus"); + for (const args of [["--bogus"], ["bogus", "--help"]]) { + const result = runVerify(...args); - expect(result.status).toBe(2); - expect(result.stdout).toBe(""); - expect(result.stderr).toContain("unknown argument: --bogus"); - expect(result.stderr).toContain("Usage: node scripts/verify.mjs"); - expect(result.stderr).not.toContain("CRABBOX_PHASE:"); - expect(result.stderr).not.toContain("[verify]"); + expect(result.status).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(`unknown argument: ${args[0]}`); + expect(result.stderr).toContain("Usage: node scripts/verify.mjs"); + expect(result.stderr).not.toContain("CRABBOX_PHASE:"); + expect(result.stderr).not.toContain("[verify]"); + } }); });