diff --git a/docs/CLEANUP-BASELINE.md b/docs/CLEANUP-BASELINE.md index 639f80a..6acd050 100644 --- a/docs/CLEANUP-BASELINE.md +++ b/docs/CLEANUP-BASELINE.md @@ -396,3 +396,78 @@ No Docker/Ubuntu run was available at M2 capture time. The portability fix (`portable_stat_perms`) replaces the only identified BSD-ism. The Ubuntu Docker command is documented in [DEVELOPMENT.md](./DEVELOPMENT.md#cross-platform-shell-support-m2). + +--- + +## Post-M4 state + +Captured: 2026-05-03 · Platform: macOS 15 (arm64) · Node 22.14.0 · pnpm 10.18.1 + +M4 extracted reusable abstractions, consolidated shared helpers, tightened +types, and removed the legacy dead-code path. `pnpm run check` remains fully +green. + +### `pnpm run check` aggregate (post-M4) + +```text +PASS lint +PASS typecheck +PASS test +PASS verify:pi +PASS verify:reviewers +PASS verify:docs +PASS verify:generated +``` + +Overall exit: **0** — all checks green (no regressions from M3). + +### What changed in M4 + +- **S-401** — `scripts/lib/safe-replace-dir.mjs` added: Node.js helper that + validates a target is a strict descendant of a safety root before replacing + it. Thin shell wrapper `scripts/lib/safe-replace-dir.sh` provided for + sourcing in shell scripts. `scripts/sync-pi-package-skills.sh` updated to + use `safe_replace_dir` from the shared helper (inline `replace_dir` removed). + +- **S-402** — `removeTarget(op)` extracted from `executeOperation()` in + `scripts/lib/skill-manager-core.mjs` and exported. The helper handles + skill, helper, and symlink removal with idempotent semantics. + `executeOperation` now delegates to `removeTarget` for all remove branches. + +- **S-403** — `skills/atlassian/shared/scripts/src/command-helpers.ts` added + with `dryRunResponse()` and `resolveFormat()` helpers. `confluence.ts`, + `jira.ts`, and `raw.ts` consume `dryRunResponse` (8 inline objects removed). + `cli.ts` imports `resolveFormat` from `command-helpers` instead of defining + it locally. All atlassian agent variants regenerated. + +- **S-404** — `skills/web-automation/shared/lib/browser.ts` created with + `getProfilePath`, `launchBrowser`, and `getPage`. `browse.ts` imports and + re-exports them. `auth.ts`, `flow.ts`, and `scan-local-app.ts` now import + directly from `lib/browser.js`. Generator updated to include `lib/` + directory in `scriptFiles` for web-automation variants. `tsconfig.json` + updated to include `lib/**/*.ts`. + +- **S-405** — `scan-local-app.ts` `page: any` parameters replaced with + `Page` from `playwright-core`. Added `GotoError` discriminated type to + narrow the `page.goto().catch()` union type safely. + +- **S-406** — `scripts/sync-pi-package-skills.sh` deleted (retired in M3, + inline `replace_dir` migrated to shared helper as part of S-401). Comment + in `skill-manager-core.mjs` referencing the deleted file updated. + Generator's `clearGeneratedRoot` fixed to preserve `node_modules` at all + depths (was only protected at root level, causing pnpm workspace packages + inside `scripts/` subdirs to lose their node_modules on regeneration). + +- **S-407** — Tests added: + - `scripts/tests/safe-replace-dir.test.mjs` (6 tests for S-401 helper) + - `scripts/tests/skill-manager-core-remove.test.mjs` (5 tests for S-402) + - `skills/atlassian/shared/scripts/tests/command-helpers.test.ts` (7 tests + for S-403 `dryRunResponse` and `resolveFormat`) + +### Test count (post-M4) + +| Suite | Tests | +|---|---| +| `pnpm run test:installer` (root scripts) | 80 | +| `atlassian/shared/scripts` | 29 | +| **Total** | **109** | diff --git a/pi-package/skills/atlassian/.generated-manifest.json b/pi-package/skills/atlassian/.generated-manifest.json index 9bfbf01..760da18 100644 --- a/pi-package/skills/atlassian/.generated-manifest.json +++ b/pi-package/skills/atlassian/.generated-manifest.json @@ -25,7 +25,13 @@ "path": "scripts/src/cli.ts", "kind": "file", "mode": "644", - "sha256": "5c4f4db76817fa9dbdae0fd0c75be302248d4b87fc0a53f6bd3c90407a75ae98" + "sha256": "90dcc029adf0625b86c5eec44c5c1fd11bbf95ffe1185016d139c8a6982d54ff" + }, + { + "path": "scripts/src/command-helpers.ts", + "kind": "file", + "mode": "644", + "sha256": "aa03d8d288c8c00485ea10d3b3a60804c1b9ee23ef265004e7912f3242dbcee7" }, { "path": "scripts/src/config.ts", @@ -37,7 +43,7 @@ "path": "scripts/src/confluence.ts", "kind": "file", "mode": "644", - "sha256": "709d5d61fdb14e37aa4eaa7175eb7f17f0ec661376c96071020fbc9574ddbb73" + "sha256": "28f65f280cd9b6119ce7eab583d0083231525ad6dc04b73389cb5dcbab5bf095" }, { "path": "scripts/src/files.ts", @@ -61,7 +67,7 @@ "path": "scripts/src/jira.ts", "kind": "file", "mode": "644", - "sha256": "485d8d618fe04eb1ce546c1694eadf15d867bc83c2a6f7df994688ab0335ea4f" + "sha256": "bec0e81a0424dd412c36988cef42c01a95f044ee8346ba626e7eb8bd79379f07" }, { "path": "scripts/src/output.ts", @@ -73,7 +79,7 @@ "path": "scripts/src/raw.ts", "kind": "file", "mode": "644", - "sha256": "2309c96dd45a03509df204803de9ecf0b5ff82fd488730f55ac5dd6a23b81dd8" + "sha256": "48fd54bd0cdb421badb58f9be2933a039fe3b9350bbe6191070c9f7bb0054670" }, { "path": "scripts/src/types.ts", diff --git a/pi-package/skills/atlassian/scripts/src/cli.ts b/pi-package/skills/atlassian/scripts/src/cli.ts index 68c1f87..a4f4fd0 100644 --- a/pi-package/skills/atlassian/scripts/src/cli.ts +++ b/pi-package/skills/atlassian/scripts/src/cli.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url"; import { Command } from "commander"; +import { resolveFormat } from "./command-helpers.js"; import { createConfluenceClient } from "./confluence.js"; import { loadConfig } from "./config.js"; import { readWorkspaceFile } from "./files.js"; @@ -11,7 +12,7 @@ import { runHealthCheck } from "./health.js"; import { createJiraClient } from "./jira.js"; import { writeOutput } from "./output.js"; import { runRawCommand } from "./raw.js"; -import type { FetchLike, OutputFormat, Writer } from "./types.js"; +import type { FetchLike, Writer } from "./types.js"; type CliContext = { cwd?: string; @@ -21,10 +22,6 @@ type CliContext = { stderr?: Writer; }; -function resolveFormat(format: string | undefined): OutputFormat { - return format === "text" ? "text" : "json"; -} - function createRuntime(context: CliContext) { const cwd = context.cwd ?? process.cwd(); const env = context.env ?? process.env; diff --git a/pi-package/skills/atlassian/scripts/src/command-helpers.ts b/pi-package/skills/atlassian/scripts/src/command-helpers.ts new file mode 100644 index 0000000..9ceca7c --- /dev/null +++ b/pi-package/skills/atlassian/scripts/src/command-helpers.ts @@ -0,0 +1,25 @@ +// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import type { CommandOutput, OutputFormat } from "./types.js"; + +/** + * Produce the standard dry-run response payload for write operations. + * + * Use this when `--dry-run` is passed to skip the actual API call and + * echo the pending request back to the caller. + * + * @example + * if (input.dryRun) return dryRunResponse(request); + */ +export function dryRunResponse(data: T): CommandOutput { + return { ok: true, dryRun: true, data }; +} + +/** + * Resolve the `--format` CLI option to a typed OutputFormat. + * + * Returns `"text"` only for the exact string `"text"`; + * all other values (including `undefined`) fall back to `"json"`. + */ +export function resolveFormat(format: string | undefined): OutputFormat { + return format === "text" ? "text" : "json"; +} diff --git a/pi-package/skills/atlassian/scripts/src/confluence.ts b/pi-package/skills/atlassian/scripts/src/confluence.ts index 25e027d..6ac333d 100644 --- a/pi-package/skills/atlassian/scripts/src/confluence.ts +++ b/pi-package/skills/atlassian/scripts/src/confluence.ts @@ -1,4 +1,5 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import { dryRunResponse } from "./command-helpers.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; @@ -178,13 +179,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, @@ -224,13 +219,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, @@ -267,13 +256,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, diff --git a/pi-package/skills/atlassian/scripts/src/jira.ts b/pi-package/skills/atlassian/scripts/src/jira.ts index af19e03..6773dff 100644 --- a/pi-package/skills/atlassian/scripts/src/jira.ts +++ b/pi-package/skills/atlassian/scripts/src/jira.ts @@ -1,5 +1,6 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. import { markdownToAdf } from "./adf.js"; +import { dryRunResponse } from "./command-helpers.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; @@ -162,13 +163,7 @@ export function createJiraClient(options: JiraClientOptions) { }, }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await send("POST", "/rest/api/3/issue", request.body); return { ok: true, data: raw }; @@ -193,13 +188,7 @@ export function createJiraClient(options: JiraClientOptions) { fields, }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); return { @@ -216,13 +205,7 @@ export function createJiraClient(options: JiraClientOptions) { body: markdownToAdf(input.body), }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); return { @@ -243,13 +226,7 @@ export function createJiraClient(options: JiraClientOptions) { }, ); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); return { diff --git a/pi-package/skills/atlassian/scripts/src/raw.ts b/pi-package/skills/atlassian/scripts/src/raw.ts index 620a259..43ed8de 100644 --- a/pi-package/skills/atlassian/scripts/src/raw.ts +++ b/pi-package/skills/atlassian/scripts/src/raw.ts @@ -1,4 +1,5 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import { dryRunResponse } from "./command-helpers.js"; import { readWorkspaceFile } from "./files.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; @@ -62,13 +63,7 @@ export async function runRawCommand( ...(body === undefined ? {} : { body }), }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const data = await sendJsonRequest({ config, diff --git a/pi-package/skills/web-automation/.generated-manifest.json b/pi-package/skills/web-automation/.generated-manifest.json index 3c0bd0b..f799067 100644 --- a/pi-package/skills/web-automation/.generated-manifest.json +++ b/pi-package/skills/web-automation/.generated-manifest.json @@ -7,13 +7,13 @@ "path": "scripts/auth.ts", "kind": "file", "mode": "644", - "sha256": "ce0a8aae0bc41b86e11aab51cc0e0cfa484a1934807f147c05c9bd38d416c066" + "sha256": "c0940f452437b05b95e58a9a7ab265fb50aa412bd672e82fedd6a37cbfb3d505" }, { "path": "scripts/browse.ts", "kind": "file", "mode": "644", - "sha256": "42da9cdc6806b8d7d8d814952ad9540033b6c6a4cbe9844ada328b2ceace67c9" + "sha256": "d7e4b4c50116032e5a00f90bca27e069dfc5bbf6eeb06ec8f8edc9e5a9792ab8" }, { "path": "scripts/check-install.js", @@ -31,7 +31,13 @@ "path": "scripts/flow.ts", "kind": "file", "mode": "644", - "sha256": "b1c256bf6a206473512a4c0555c891893a48025529da282fa6cd07e68ad3d051" + "sha256": "94f3e7987cab253dc3c9e80656a11759fada13b3915608bff7ae08418602f366" + }, + { + "path": "scripts/lib/browser.ts", + "kind": "file", + "mode": "644", + "sha256": "879b5f883ff1f888d45ed20be05c2d9bc3d6fe5305a1972b7d49a7e6c0e24934" }, { "path": "scripts/package.json", @@ -49,7 +55,7 @@ "path": "scripts/scan-local-app.ts", "kind": "file", "mode": "644", - "sha256": "3f42f9bb2d355fefc8645d2b2acfa3107bd87f9c2579b2631c94132bed0abea4" + "sha256": "9e1818c254a633e087715609152936dcb3613a0aa724d40a8a13460510691dc7" }, { "path": "scripts/scrape.ts", @@ -79,7 +85,7 @@ "path": "scripts/tsconfig.json", "kind": "file", "mode": "644", - "sha256": "5f9a83c8caab167eb20defbb5afde58f2bb573a300af99654997dcb3372408e0" + "sha256": "e5f22d72266068cf410976c880511f2ec1875445256e11739a5e1de6ffedf38d" }, { "path": "scripts/turndown-plugin-gfm.d.ts", diff --git a/pi-package/skills/web-automation/scripts/auth.ts b/pi-package/skills/web-automation/scripts/auth.ts index 272ee6d..b06e846 100644 --- a/pi-package/skills/web-automation/scripts/auth.ts +++ b/pi-package/skills/web-automation/scripts/auth.ts @@ -11,7 +11,7 @@ * npx tsx auth.ts --url "https://example.com" --type auto */ -import { getPage, launchBrowser } from './browse.js'; +import { getPage, launchBrowser } from './lib/browser.js'; import parseArgs from 'minimist'; import type { Page, BrowserContext } from 'playwright-core'; import { createInterface } from 'readline'; diff --git a/pi-package/skills/web-automation/scripts/browse.ts b/pi-package/skills/web-automation/scripts/browse.ts index 3d27c9e..e270db8 100644 --- a/pi-package/skills/web-automation/scripts/browse.ts +++ b/pi-package/skills/web-automation/scripts/browse.ts @@ -10,12 +10,13 @@ * npx tsx browse.ts --url "https://example.com" --headless false --wait 5000 */ -import { launchPersistentContext } from 'cloakbrowser'; -import { homedir } from 'os'; -import { join } from 'path'; -import { existsSync, mkdirSync } from 'fs'; import parseArgs from 'minimist'; -import type { Page, BrowserContext } from 'playwright-core'; +import type { BrowserContext } from 'playwright-core'; + +import { getProfilePath, launchBrowser, getPage } from './lib/browser.js'; + +// Re-export shared helpers so existing imports of browse.ts continue to work. +export { getProfilePath, launchBrowser, getPage }; interface BrowseOptions { url: string; @@ -37,36 +38,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -const getProfilePath = (): string => { - const customPath = process.env.CLOAKBROWSER_PROFILE_PATH; - if (customPath) return customPath; - - const profileDir = join(homedir(), '.cloakbrowser-profile'); - if (!existsSync(profileDir)) { - mkdirSync(profileDir, { recursive: true }); - } - return profileDir; -}; - -export async function launchBrowser(options: { - headless?: boolean; -}): Promise { - const profilePath = getProfilePath(); - const envHeadless = process.env.CLOAKBROWSER_HEADLESS; - const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true); - - console.log(`Using profile: ${profilePath}`); - console.log(`Headless mode: ${headless}`); - - const context = await launchPersistentContext({ - userDataDir: profilePath, - headless, - humanize: true, - }); - - return context; -} - export async function browse(options: BrowseOptions): Promise { const browser = await launchBrowser({ headless: options.headless }); const page = browser.pages()[0] || await browser.newPage(); @@ -112,14 +83,6 @@ export async function browse(options: BrowseOptions): Promise { } } -export async function getPage(options?: { - headless?: boolean; -}): Promise<{ page: Page; browser: BrowserContext }> { - const browser = await launchBrowser({ headless: options?.headless }); - const page = browser.pages()[0] || await browser.newPage(); - return { page, browser }; -} - async function main() { const args = parseArgs(process.argv.slice(2), { string: ['url', 'output'], diff --git a/pi-package/skills/web-automation/scripts/flow.ts b/pi-package/skills/web-automation/scripts/flow.ts index dfe2d8c..6b8b5e6 100644 --- a/pi-package/skills/web-automation/scripts/flow.ts +++ b/pi-package/skills/web-automation/scripts/flow.ts @@ -3,7 +3,7 @@ import parseArgs from 'minimist'; import type { Page } from 'playwright-core'; -import { launchBrowser } from './browse'; +import { launchBrowser } from './lib/browser.js'; type Step = | { action: 'goto'; url: string } diff --git a/pi-package/skills/web-automation/scripts/lib/browser.ts b/pi-package/skills/web-automation/scripts/lib/browser.ts new file mode 100644 index 0000000..5723bad --- /dev/null +++ b/pi-package/skills/web-automation/scripts/lib/browser.ts @@ -0,0 +1,76 @@ +// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`. +/** + * Shared browser-launch and profile helpers for web-automation scripts. + * + * Centralises the three reusable primitives that every command entry point + * needs: + * - getProfilePath() — resolve the persistent CloakBrowser profile dir + * - launchBrowser() — launch a CloakBrowser persistent context + * - getPage() — get a ready Page + BrowserContext pair + * + * All command entry points (auth.ts, browse.ts, flow.ts, scan-local-app.ts) + * import from here instead of duplicating these bodies. + */ + +import { launchPersistentContext } from 'cloakbrowser'; +import { existsSync, mkdirSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import type { BrowserContext, Page } from 'playwright-core'; + +/** + * Return the path to the persistent CloakBrowser profile directory. + * + * Uses `CLOAKBROWSER_PROFILE_PATH` env var when set; otherwise defaults to + * `~/.cloakbrowser-profile/` and creates it if it does not exist. + */ +export function getProfilePath(): string { + const customPath = process.env.CLOAKBROWSER_PROFILE_PATH; + if (customPath) return customPath; + + const profileDir = join(homedir(), '.cloakbrowser-profile'); + if (!existsSync(profileDir)) { + mkdirSync(profileDir, { recursive: true }); + } + return profileDir; +} + +/** + * Launch a CloakBrowser persistent context with the shared profile. + * + * Headless mode is resolved in order: + * 1. `options.headless` (explicit caller preference) + * 2. `CLOAKBROWSER_HEADLESS` env var + * 3. `true` (safe default) + */ +export async function launchBrowser(options: { + headless?: boolean; +}): Promise { + const profilePath = getProfilePath(); + const envHeadless = process.env.CLOAKBROWSER_HEADLESS; + const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true); + + console.log(`Using profile: ${profilePath}`); + console.log(`Headless mode: ${headless}`); + + const context = await launchPersistentContext({ + userDataDir: profilePath, + headless, + humanize: true, + }); + + return context; +} + +/** + * Return a ready `{ page, browser }` pair using the shared persistent profile. + * + * Re-uses the first existing page or opens a new one if the context is empty. + */ +export async function getPage(options?: { + headless?: boolean; +}): Promise<{ page: Page; browser: BrowserContext }> { + const browser = await launchBrowser({ headless: options?.headless }); + const page = browser.pages()[0] || (await browser.newPage()); + return { page, browser }; +} diff --git a/pi-package/skills/web-automation/scripts/scan-local-app.ts b/pi-package/skills/web-automation/scripts/scan-local-app.ts index 00e213e..cf8d190 100644 --- a/pi-package/skills/web-automation/scripts/scan-local-app.ts +++ b/pi-package/skills/web-automation/scripts/scan-local-app.ts @@ -3,7 +3,8 @@ import { mkdirSync, writeFileSync } from 'fs'; import { dirname, resolve } from 'path'; -import { getPage } from './browse.js'; +import type { Page } from 'playwright-core'; +import { getPage } from './lib/browser.js'; type NavResult = { requestedUrl: string; @@ -40,30 +41,34 @@ function getRoutes(baseUrl: string): string[] { return [baseUrl]; } -async function gotoWithStatus(page: any, url: string): Promise { +type GotoError = { error: unknown }; + +async function gotoWithStatus(page: Page, url: string): Promise { const response = await page .goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) - .catch((error: unknown) => ({ error })); + .catch((error: unknown): GotoError => ({ error })); - if (response?.error) { + if (response !== null && response !== undefined && 'error' in response) { + const gotoError = response as GotoError; return { requestedUrl: url, url: page.url(), status: null, title: await page.title().catch(() => ''), - error: String(response.error), + error: String(gotoError.error), }; } + const httpResponse = response as Awaited>; return { requestedUrl: url, url: page.url(), - status: response ? response.status() : null, + status: httpResponse ? httpResponse.status() : null, title: await page.title().catch(() => ''), }; } -async function textOrNull(page: any, selector: string): Promise { +async function textOrNull(page: Page, selector: string): Promise { const locator = page.locator(selector).first(); try { if ((await locator.count()) === 0) return null; @@ -74,7 +79,7 @@ async function textOrNull(page: any, selector: string): Promise { } } -async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { +async function loginIfConfigured(page: Page, baseUrl: string, lines: string[]) { const loginPath = env('SCAN_LOGIN_PATH'); const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); @@ -110,7 +115,7 @@ async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { lines.push(''); } -async function checkRoutes(page: any, baseUrl: string, lines: string[]) { +async function checkRoutes(page: Page, baseUrl: string, lines: string[]) { const routes = getRoutes(baseUrl); const routeChecks: RouteCheck[] = []; diff --git a/pi-package/skills/web-automation/scripts/tsconfig.json b/pi-package/skills/web-automation/scripts/tsconfig.json index 4c23583..689017a 100644 --- a/pi-package/skills/web-automation/scripts/tsconfig.json +++ b/pi-package/skills/web-automation/scripts/tsconfig.json @@ -11,6 +11,6 @@ "outDir": "./dist", "rootDir": "." }, - "include": ["*.ts"], + "include": ["*.ts", "lib/**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/scripts/generate-skills.mjs b/scripts/generate-skills.mjs index 75e66b0..a808062 100644 --- a/scripts/generate-skills.mjs +++ b/scripts/generate-skills.mjs @@ -487,8 +487,12 @@ async function generateReviewerRuntimePi(repoRoot, writeRoot) { /** * Clear generated content in a root, preserving: - * - node_modules (installed by pnpm) + * - node_modules (installed by pnpm) — at any depth * - .generated-manifest.json (will be rewritten after generation) + * + * Subdirectories are always recursed into before removal so that + * node_modules trees nested at any depth (e.g. scripts/node_modules inside + * atlassian or web-automation variants) are preserved. */ async function clearGeneratedRoot(rootDir) { let entries; @@ -501,7 +505,18 @@ async function clearGeneratedRoot(rootDir) { for (const entry of entries) { if (entry.name === "node_modules") continue; if (entry.name === MANIFEST_FILENAME) continue; - await rm(path.join(rootDir, entry.name), { recursive: true, force: true }); + const fullPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + // Always recurse so node_modules at any depth is preserved. + await clearGeneratedRoot(fullPath); + // Remove the directory only if nothing protected remains inside it. + const remaining = await readdir(fullPath).catch(() => []); + if (remaining.length === 0) { + await rm(fullPath, { force: true }); + } + } else { + await rm(fullPath, { force: true }); + } } } @@ -521,6 +536,7 @@ const SCRIPTS_SKILL_CONFIGS = { "check-install.js", "extract.js", "flow.ts", + "lib", "scan-local-app.ts", "scrape.ts", "test-full.ts", diff --git a/scripts/lib/safe-replace-dir.mjs b/scripts/lib/safe-replace-dir.mjs new file mode 100644 index 0000000..0f6956d --- /dev/null +++ b/scripts/lib/safe-replace-dir.mjs @@ -0,0 +1,77 @@ +/** + * safe-replace-dir.mjs — safely replace a directory within a safety-root boundary + * + * Exports: + * safeReplaceDir(source, target, safetyRoot) → Promise + * + * Usage: + * import { safeReplaceDir } from "./lib/safe-replace-dir.mjs"; + * await safeReplaceDir("/path/to/source", "/safe/root/target", "/safe/root"); + * + * Safety contract: + * - `target` must be a strict descendant of `safetyRoot` (not equal to it). + * - `target` must be a non-empty path. + * - Throws with a descriptive message if either constraint is violated. + * + * Behaviour: + * - Removes any existing content at `target` (rm -rf equivalent). + * - Creates `target` (and any missing parent directories). + * - Copies all files from `source` into `target`. + */ + +import { cp, mkdir, realpath, rm } from "node:fs/promises"; +import path from "node:path"; + +/** + * Safely replace `target` with the contents of `source`, enforcing that + * `target` is a strict descendant of `safetyRoot`. + * + * @param {string} source - Directory to copy from. + * @param {string} target - Directory to replace (will be removed then recreated). + * @param {string} safetyRoot - Ancestor boundary; `target` must be inside this. + * @returns {Promise} + */ +export async function safeReplaceDir(source, target, safetyRoot) { + if (!target || target === "") { + throw new Error(`Refusing to replace unsafe target: (empty string)`); + } + + const resolvedSafety = path.resolve(safetyRoot); + const resolvedTarget = path.resolve(target); + + // Lexical check: target must be a strict descendant of safetyRoot. + const relative = path.relative(resolvedSafety, resolvedTarget); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative) || relative === "") { + throw new Error(`Refusing to replace target outside safety root: ${target}`); + } + + // Real-path check: resolve the deepest existing ancestor of target's parent + // and verify it lies inside the real (symlink-resolved) safety root. + // This blocks a symlinked parent directory from redirecting outside the boundary. + const realSafety = await realpath(resolvedSafety); + let checkPath = path.dirname(resolvedTarget); + for (;;) { + try { + const realAncestor = await realpath(checkPath); + const realRel = path.relative(realSafety, realAncestor); + if (realRel.startsWith("..") || path.isAbsolute(realRel)) { + throw new Error(`Refusing to replace target outside safety root: ${target}`); + } + break; // validation passed + } catch (err) { + if (err.code === "ENOENT") { + const parent = path.dirname(checkPath); + if (parent === checkPath) { + throw new Error(`Refusing to replace target outside safety root: ${target}`, { cause: err }); + } + checkPath = parent; + continue; + } + throw err; + } + } + + await rm(resolvedTarget, { recursive: true, force: true }); + await mkdir(resolvedTarget, { recursive: true }); + await cp(source, resolvedTarget, { recursive: true, force: true }); +} diff --git a/scripts/lib/safe-replace-dir.sh b/scripts/lib/safe-replace-dir.sh new file mode 100755 index 0000000..b444ec4 --- /dev/null +++ b/scripts/lib/safe-replace-dir.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# safe-replace-dir.sh — safely replace a directory within a safety-root boundary +# +# Provides safe_replace_dir() for sourcing, or run standalone: +# ./scripts/lib/safe-replace-dir.sh +# +# Safety contract (mirrors safe-replace-dir.mjs): +# - must be a non-empty path. +# - must be a strict descendant of (not equal to it). +# - Prints an error and returns/exits 1 if either constraint is violated. +# +# Usage (sourced): +# source "$(dirname "${BASH_SOURCE[0]}")/safe-replace-dir.sh" +# safe_replace_dir "$source" "$target" "$safety_root" +# +# Usage (standalone): +# ./scripts/lib/safe-replace-dir.sh /path/to/source /safe/root/target /safe/root + +safe_replace_dir() { + local source=$1 + local target=$2 + local safety_root=$3 + + if [[ -z "$target" ]]; then + echo "safe_replace_dir: refusing to replace unsafe target: (empty string)" >&2 + return 1 + fi + + # Resolve the real (symlink-resolved) safety root. + local abs_safety + abs_safety=$(cd "$safety_root" 2>/dev/null && pwd -P) || { + echo "safe_replace_dir: safety root does not exist: $safety_root" >&2 + return 1 + } + + # Build an absolute lexical path for target's parent directory. + local target_parent target_base + target_base=$(basename "$target") + target_parent=$(dirname "$target") + # Make target_parent absolute without relying on cd (target may not exist yet). + if [[ "$target_parent" != /* ]]; then + target_parent="${PWD}/${target_parent}" + fi + + # Walk up from target_parent to find the deepest existing directory, + # accumulating the non-existing path suffix as we go. + local suffix="" + local walk="$target_parent" + while [[ ! -d "$walk" ]]; do + local component + component=$(basename "$walk") + if [[ -z "$suffix" ]]; then + suffix="$component" + else + suffix="${component}/${suffix}" + fi + local next + next=$(dirname "$walk") + if [[ "$next" == "$walk" ]]; then + echo "safe_replace_dir: could not find existing ancestor for: $target" >&2 + return 1 + fi + walk="$next" + done + + # Resolve the real path of the existing ancestor (follows symlinks). + local abs_parent + abs_parent=$(cd "$walk" && pwd -P) || { + echo "safe_replace_dir: could not resolve parent directory: $walk" >&2 + return 1 + } + + # Reconstruct the full absolute target path. + local abs_target + if [[ -n "$suffix" ]]; then + abs_target="${abs_parent}/${suffix}/${target_base}" + else + abs_target="${abs_parent}/${target_base}" + fi + + # Check that abs_target is strictly inside abs_safety + case "$abs_target" in + "${abs_safety}/"*) ;; + *) + echo "safe_replace_dir: refusing to replace target outside safety root: $target" >&2 + return 1 + ;; + esac + + rm -rf "$abs_target" + mkdir -p "$abs_target" + cp -R "${source}/." "$abs_target/" +} + +# Allow standalone use +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + if [[ $# -ne 3 ]]; then + echo "Usage: $0 " >&2 + exit 1 + fi + safe_replace_dir "$1" "$2" "$3" || exit 1 +fi diff --git a/scripts/lib/skill-manager-core.mjs b/scripts/lib/skill-manager-core.mjs index 8afd84b..814785f 100644 --- a/scripts/lib/skill-manager-core.mjs +++ b/scripts/lib/skill-manager-core.mjs @@ -532,6 +532,24 @@ export async function buildOperationPlan({ selections, repoRoot = process.cwd(), return { operations, prompts, reportRows, assumeYes }; } +/** + * Remove the target of an operation (skill, helper, or superpowers). + * + * Validates that the target is within the skills root before removing. + * Handles both regular directories and symbolic links. + * Idempotent: succeeds even when the target does not exist. + * + * @param {object} op - Operation object with at least `target` and `skillsRoot`. + * @returns {Promise} Operation with `status: "ok"`. + */ +export async function removeTarget(op) { + await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT }); + const info = existsSync(op.target) ? await lstat(op.target) : null; + if (info?.isSymbolicLink()) await unlink(op.target); + else await rm(op.target, { recursive: true, force: true }); + return { ...op, status: "ok" }; +} + export async function validateRemoveTarget(target, skillsRoot, { repoRoot = process.cwd() } = {}) { const resolvedRoot = path.resolve(skillsRoot); const resolvedTarget = path.resolve(target); @@ -599,8 +617,6 @@ export async function executeOperation(op) { if (op.kind === "package-skill") return { ...op, status: "included" }; if (op.kind === "sync-pi-package") { // Use the canonical generator (pnpm run sync:pi / node scripts/generate-skills.mjs). - // The legacy sync-pi-package-skills.sh is retired in M3; it bypassed the - // generator and copied skills/*/pi into pi-package directly, corrupting manifests. runCommand(process.execPath, [path.join(op.repoRoot, "scripts", "generate-skills.mjs")], { cwd: op.repoRoot }); return { ...op, status: "ok" }; } @@ -610,33 +626,19 @@ export async function executeOperation(op) { return { ...op, status: "ok" }; } if (op.kind === "skill") { - if (op.action === "remove") { - await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT }); - const info = existsSync(op.target) ? await lstat(op.target) : null; - if (info?.isSymbolicLink()) await unlink(op.target); - else await rm(op.target, { recursive: true, force: true }); - return { ...op, status: "ok" }; - } + if (op.action === "remove") return removeTarget(op); await copyDirectoryReplacing(op.source, op.target); return { ...op, status: "ok" }; } if (op.kind === "helper") { - if (op.action === "remove") { - await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT }); - const info = existsSync(op.target) ? await lstat(op.target) : null; - if (info?.isSymbolicLink()) await unlink(op.target); - else await rm(op.target, { recursive: true, force: true }); - return { ...op, status: "ok" }; - } + if (op.action === "remove") return removeTarget(op); await installHelperAllowlist(op); return { ...op, status: "ok" }; } if (op.kind === "superpowers") { + if (op.action === "remove") return removeTarget(op); await mkdir(path.dirname(op.target), { recursive: true }); - if (op.action === "remove") { - await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT }); - await rm(op.target, { recursive: true, force: true }); - } else if (op.mode === "copy") { + if (op.mode === "copy") { await copyDirectoryReplacing(op.source, op.target); } else { await rm(op.target, { recursive: true, force: true }); diff --git a/scripts/sync-pi-package-skills.sh b/scripts/sync-pi-package-skills.sh deleted file mode 100755 index 2908b34..0000000 --- a/scripts/sync-pi-package-skills.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) -TARGET_ROOT="${ROOT_DIR}/pi-package/skills" -SKILL_FAMILIES=( - "atlassian" - "create-plan" - "do-task" - "implement-plan" - "web-automation" -) - -extract_skill_name() { - local skill_md=$1 - awk '/^name:/ { print $2; exit }' "$skill_md" -} - -replace_dir() { - local source=$1 - local target=$2 - - if [[ -z "$target" || "$target" == "/" || "$target" == "." || "$target" == ".." ]]; then - echo "Refusing to sync into unsafe target: $target" >&2 - exit 1 - fi - - case "$target" in - "${ROOT_DIR}"/*) ;; - *) - echo "Refusing to remove target outside repo root: $target" >&2 - exit 1 - ;; - esac - - rm -rf "$target" - mkdir -p "$target" - cp -R "${source}/." "$target/" -} - -rm -rf "$TARGET_ROOT" -mkdir -p "$TARGET_ROOT" - -for family in "${SKILL_FAMILIES[@]}"; do - source_dir="${ROOT_DIR}/skills/${family}/pi" - skill_md="${source_dir}/SKILL.md" - - if [[ ! -f "$skill_md" ]]; then - echo "Missing source SKILL.md: $skill_md" >&2 - exit 1 - fi - - skill_name=$(extract_skill_name "$skill_md") - if [[ -z "$skill_name" ]]; then - echo "Could not derive skill name from $skill_md" >&2 - exit 1 - fi - - replace_dir "$source_dir" "${TARGET_ROOT}/${skill_name}" -done - -echo "Synced pi package skill mirror into ${TARGET_ROOT}." diff --git a/scripts/tests/safe-replace-dir.test.mjs b/scripts/tests/safe-replace-dir.test.mjs new file mode 100644 index 0000000..21c5fd0 --- /dev/null +++ b/scripts/tests/safe-replace-dir.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { safeReplaceDir } from "../lib/safe-replace-dir.mjs"; + +// ── Happy path ──────────────────────────────────────────────────────────── + +test("safeReplaceDir copies source content into the target", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-copy-")); + try { + const safetyRoot = path.join(dir, "root"); + const source = path.join(dir, "source"); + const target = path.join(safetyRoot, "target"); + + await mkdir(source, { recursive: true }); + await writeFile(path.join(source, "file.txt"), "hello"); + await mkdir(safetyRoot, { recursive: true }); + + await safeReplaceDir(source, target, safetyRoot); + + const content = await readFile(path.join(target, "file.txt"), "utf8"); + assert.equal(content, "hello"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("safeReplaceDir removes existing content before replacing", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-stale-")); + try { + const safetyRoot = path.join(dir, "root"); + const source = path.join(dir, "source"); + const target = path.join(safetyRoot, "target"); + + await mkdir(target, { recursive: true }); + await writeFile(path.join(target, "old.txt"), "stale"); + await mkdir(source, { recursive: true }); + await writeFile(path.join(source, "new.txt"), "fresh"); + + await safeReplaceDir(source, target, safetyRoot); + + const files = await readdir(target); + assert.deepEqual(files.sort(), ["new.txt"]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("safeReplaceDir creates target parent directories if they do not exist", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-mkdir-")); + try { + const safetyRoot = path.join(dir, "root"); + const source = path.join(dir, "source"); + const target = path.join(safetyRoot, "nested", "target"); + + await mkdir(source, { recursive: true }); + await writeFile(path.join(source, "data.txt"), "data"); + await mkdir(safetyRoot, { recursive: true }); + // nested parent does NOT exist yet + + await safeReplaceDir(source, target, safetyRoot); + + const content = await readFile(path.join(target, "data.txt"), "utf8"); + assert.equal(content, "data"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("safeReplaceDir creates deeply nested parent directories (2+ levels missing)", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-deep-")); + try { + const safetyRoot = path.join(dir, "root"); + const source = path.join(dir, "source"); + // two parent levels (a/b) do NOT exist under safetyRoot + const target = path.join(safetyRoot, "a", "b", "target"); + + await mkdir(source, { recursive: true }); + await writeFile(path.join(source, "deep.txt"), "deep"); + await mkdir(safetyRoot, { recursive: true }); + // a/ and a/b/ intentionally NOT created + + await safeReplaceDir(source, target, safetyRoot); + + const content = await readFile(path.join(target, "deep.txt"), "utf8"); + assert.equal(content, "deep"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// ── Safety checks ───────────────────────────────────────────────────────── + +test("safeReplaceDir refuses when target is outside the safety root", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-outside-")); + try { + const safetyRoot = path.join(dir, "root"); + const source = path.join(dir, "source"); + const outside = path.join(dir, "outside"); + + await mkdir(source, { recursive: true }); + await mkdir(safetyRoot, { recursive: true }); + + await assert.rejects( + () => safeReplaceDir(source, outside, safetyRoot), + /outside safety root/, + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("safeReplaceDir refuses when target equals the safety root", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "safe-replace-same-")); + try { + const safetyRoot = path.join(dir, "root"); + const source = path.join(dir, "source"); + + await mkdir(source, { recursive: true }); + await mkdir(safetyRoot, { recursive: true }); + + await assert.rejects( + () => safeReplaceDir(source, safetyRoot, safetyRoot), + /outside safety root/, + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("safeReplaceDir refuses an empty target string", async () => { + await assert.rejects( + () => safeReplaceDir("/any", "", "/root"), + /unsafe target/, + ); +}); diff --git a/scripts/tests/skill-manager-core-remove.test.mjs b/scripts/tests/skill-manager-core-remove.test.mjs new file mode 100644 index 0000000..5893277 --- /dev/null +++ b/scripts/tests/skill-manager-core-remove.test.mjs @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, rm, lstat, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { removeTarget } from "../lib/skill-manager-core.mjs"; + +// ── Happy path: remove existing directory ───────────────────────────────── + +test("removeTarget removes an installed skill directory", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-dir-")); + try { + const skillsRoot = path.join(dir, "skills"); + const target = path.join(skillsRoot, "create-plan"); + await mkdir(target, { recursive: true }); + await writeFile(path.join(target, "SKILL.md"), "---\nname: create-plan\n---\n"); + + const op = { kind: "skill", action: "remove", target, skillsRoot }; + const result = await removeTarget(op); + + assert.equal(result.status, "ok"); + let exists = true; + try { + await lstat(target); + } catch { + exists = false; + } + assert.equal(exists, false, "target directory should be gone"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// ── Happy path: remove symbolic link ───────────────────────────────────── + +test("removeTarget removes a symlink without following it", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-sym-")); + try { + const skillsRoot = path.join(dir, "skills"); + const realDir = path.join(dir, "real-skill"); + const target = path.join(skillsRoot, "create-plan"); + + await mkdir(skillsRoot, { recursive: true }); + await mkdir(realDir, { recursive: true }); + await writeFile(path.join(realDir, "SKILL.md"), "---\nname: create-plan\n---\n"); + await symlink(realDir, target, "dir"); + + const op = { kind: "skill", action: "remove", target, skillsRoot }; + const result = await removeTarget(op); + + assert.equal(result.status, "ok"); + + // symlink itself should be gone + let symlinkExists = true; + try { + await lstat(target); + } catch { + symlinkExists = false; + } + assert.equal(symlinkExists, false, "symlink should be removed"); + + // real directory should still exist + const realStat = await lstat(realDir); + assert.ok(realStat.isDirectory(), "real directory must not be touched"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// ── Missing skill (partial state): target does not exist ───────────────── + +test("removeTarget succeeds when target does not exist (idempotent)", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-missing-")); + try { + const skillsRoot = path.join(dir, "skills"); + const target = path.join(skillsRoot, "create-plan"); + await mkdir(skillsRoot, { recursive: true }); + // target intentionally NOT created + + const op = { kind: "skill", action: "remove", target, skillsRoot }; + const result = await removeTarget(op); + + assert.equal(result.status, "ok"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// ── Partial state: directory exists but is empty ───────────────────────── + +test("removeTarget removes an empty skill directory", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-empty-")); + try { + const skillsRoot = path.join(dir, "skills"); + const target = path.join(skillsRoot, "create-plan"); + await mkdir(target, { recursive: true }); + // directory exists but has no SKILL.md (partial install state) + + const op = { kind: "skill", action: "remove", target, skillsRoot }; + const result = await removeTarget(op); + + assert.equal(result.status, "ok"); + let exists = true; + try { + await lstat(target); + } catch { + exists = false; + } + assert.equal(exists, false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// ── Safety: refuses to remove path outside skills root ──────────────────── + +test("removeTarget refuses to remove a path outside the skills root", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "smc-remove-outside-")); + try { + const skillsRoot = path.join(dir, "skills"); + const outsideTarget = path.join(dir, "outside"); + await mkdir(skillsRoot, { recursive: true }); + await mkdir(outsideTarget, { recursive: true }); + + const op = { + kind: "skill", + action: "remove", + target: outsideTarget, + skillsRoot, + }; + + await assert.rejects(() => removeTarget(op), /outside skills root/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/skills/atlassian/claude-code/.generated-manifest.json b/skills/atlassian/claude-code/.generated-manifest.json index 1cfc506..8c56602 100644 --- a/skills/atlassian/claude-code/.generated-manifest.json +++ b/skills/atlassian/claude-code/.generated-manifest.json @@ -25,7 +25,13 @@ "path": "scripts/src/cli.ts", "kind": "file", "mode": "644", - "sha256": "5c4f4db76817fa9dbdae0fd0c75be302248d4b87fc0a53f6bd3c90407a75ae98" + "sha256": "90dcc029adf0625b86c5eec44c5c1fd11bbf95ffe1185016d139c8a6982d54ff" + }, + { + "path": "scripts/src/command-helpers.ts", + "kind": "file", + "mode": "644", + "sha256": "aa03d8d288c8c00485ea10d3b3a60804c1b9ee23ef265004e7912f3242dbcee7" }, { "path": "scripts/src/config.ts", @@ -37,7 +43,7 @@ "path": "scripts/src/confluence.ts", "kind": "file", "mode": "644", - "sha256": "709d5d61fdb14e37aa4eaa7175eb7f17f0ec661376c96071020fbc9574ddbb73" + "sha256": "28f65f280cd9b6119ce7eab583d0083231525ad6dc04b73389cb5dcbab5bf095" }, { "path": "scripts/src/files.ts", @@ -61,7 +67,7 @@ "path": "scripts/src/jira.ts", "kind": "file", "mode": "644", - "sha256": "485d8d618fe04eb1ce546c1694eadf15d867bc83c2a6f7df994688ab0335ea4f" + "sha256": "bec0e81a0424dd412c36988cef42c01a95f044ee8346ba626e7eb8bd79379f07" }, { "path": "scripts/src/output.ts", @@ -73,7 +79,7 @@ "path": "scripts/src/raw.ts", "kind": "file", "mode": "644", - "sha256": "2309c96dd45a03509df204803de9ecf0b5ff82fd488730f55ac5dd6a23b81dd8" + "sha256": "48fd54bd0cdb421badb58f9be2933a039fe3b9350bbe6191070c9f7bb0054670" }, { "path": "scripts/src/types.ts", diff --git a/skills/atlassian/claude-code/scripts/src/cli.ts b/skills/atlassian/claude-code/scripts/src/cli.ts index 68c1f87..a4f4fd0 100644 --- a/skills/atlassian/claude-code/scripts/src/cli.ts +++ b/skills/atlassian/claude-code/scripts/src/cli.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url"; import { Command } from "commander"; +import { resolveFormat } from "./command-helpers.js"; import { createConfluenceClient } from "./confluence.js"; import { loadConfig } from "./config.js"; import { readWorkspaceFile } from "./files.js"; @@ -11,7 +12,7 @@ import { runHealthCheck } from "./health.js"; import { createJiraClient } from "./jira.js"; import { writeOutput } from "./output.js"; import { runRawCommand } from "./raw.js"; -import type { FetchLike, OutputFormat, Writer } from "./types.js"; +import type { FetchLike, Writer } from "./types.js"; type CliContext = { cwd?: string; @@ -21,10 +22,6 @@ type CliContext = { stderr?: Writer; }; -function resolveFormat(format: string | undefined): OutputFormat { - return format === "text" ? "text" : "json"; -} - function createRuntime(context: CliContext) { const cwd = context.cwd ?? process.cwd(); const env = context.env ?? process.env; diff --git a/skills/atlassian/claude-code/scripts/src/command-helpers.ts b/skills/atlassian/claude-code/scripts/src/command-helpers.ts new file mode 100644 index 0000000..9ceca7c --- /dev/null +++ b/skills/atlassian/claude-code/scripts/src/command-helpers.ts @@ -0,0 +1,25 @@ +// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import type { CommandOutput, OutputFormat } from "./types.js"; + +/** + * Produce the standard dry-run response payload for write operations. + * + * Use this when `--dry-run` is passed to skip the actual API call and + * echo the pending request back to the caller. + * + * @example + * if (input.dryRun) return dryRunResponse(request); + */ +export function dryRunResponse(data: T): CommandOutput { + return { ok: true, dryRun: true, data }; +} + +/** + * Resolve the `--format` CLI option to a typed OutputFormat. + * + * Returns `"text"` only for the exact string `"text"`; + * all other values (including `undefined`) fall back to `"json"`. + */ +export function resolveFormat(format: string | undefined): OutputFormat { + return format === "text" ? "text" : "json"; +} diff --git a/skills/atlassian/claude-code/scripts/src/confluence.ts b/skills/atlassian/claude-code/scripts/src/confluence.ts index 25e027d..6ac333d 100644 --- a/skills/atlassian/claude-code/scripts/src/confluence.ts +++ b/skills/atlassian/claude-code/scripts/src/confluence.ts @@ -1,4 +1,5 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import { dryRunResponse } from "./command-helpers.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; @@ -178,13 +179,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, @@ -224,13 +219,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, @@ -267,13 +256,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, diff --git a/skills/atlassian/claude-code/scripts/src/jira.ts b/skills/atlassian/claude-code/scripts/src/jira.ts index af19e03..6773dff 100644 --- a/skills/atlassian/claude-code/scripts/src/jira.ts +++ b/skills/atlassian/claude-code/scripts/src/jira.ts @@ -1,5 +1,6 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. import { markdownToAdf } from "./adf.js"; +import { dryRunResponse } from "./command-helpers.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; @@ -162,13 +163,7 @@ export function createJiraClient(options: JiraClientOptions) { }, }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await send("POST", "/rest/api/3/issue", request.body); return { ok: true, data: raw }; @@ -193,13 +188,7 @@ export function createJiraClient(options: JiraClientOptions) { fields, }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); return { @@ -216,13 +205,7 @@ export function createJiraClient(options: JiraClientOptions) { body: markdownToAdf(input.body), }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); return { @@ -243,13 +226,7 @@ export function createJiraClient(options: JiraClientOptions) { }, ); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); return { diff --git a/skills/atlassian/claude-code/scripts/src/raw.ts b/skills/atlassian/claude-code/scripts/src/raw.ts index 620a259..43ed8de 100644 --- a/skills/atlassian/claude-code/scripts/src/raw.ts +++ b/skills/atlassian/claude-code/scripts/src/raw.ts @@ -1,4 +1,5 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import { dryRunResponse } from "./command-helpers.js"; import { readWorkspaceFile } from "./files.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; @@ -62,13 +63,7 @@ export async function runRawCommand( ...(body === undefined ? {} : { body }), }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const data = await sendJsonRequest({ config, diff --git a/skills/atlassian/codex/.generated-manifest.json b/skills/atlassian/codex/.generated-manifest.json index d09eaa9..835fa20 100644 --- a/skills/atlassian/codex/.generated-manifest.json +++ b/skills/atlassian/codex/.generated-manifest.json @@ -25,7 +25,13 @@ "path": "scripts/src/cli.ts", "kind": "file", "mode": "644", - "sha256": "5c4f4db76817fa9dbdae0fd0c75be302248d4b87fc0a53f6bd3c90407a75ae98" + "sha256": "90dcc029adf0625b86c5eec44c5c1fd11bbf95ffe1185016d139c8a6982d54ff" + }, + { + "path": "scripts/src/command-helpers.ts", + "kind": "file", + "mode": "644", + "sha256": "aa03d8d288c8c00485ea10d3b3a60804c1b9ee23ef265004e7912f3242dbcee7" }, { "path": "scripts/src/config.ts", @@ -37,7 +43,7 @@ "path": "scripts/src/confluence.ts", "kind": "file", "mode": "644", - "sha256": "709d5d61fdb14e37aa4eaa7175eb7f17f0ec661376c96071020fbc9574ddbb73" + "sha256": "28f65f280cd9b6119ce7eab583d0083231525ad6dc04b73389cb5dcbab5bf095" }, { "path": "scripts/src/files.ts", @@ -61,7 +67,7 @@ "path": "scripts/src/jira.ts", "kind": "file", "mode": "644", - "sha256": "485d8d618fe04eb1ce546c1694eadf15d867bc83c2a6f7df994688ab0335ea4f" + "sha256": "bec0e81a0424dd412c36988cef42c01a95f044ee8346ba626e7eb8bd79379f07" }, { "path": "scripts/src/output.ts", @@ -73,7 +79,7 @@ "path": "scripts/src/raw.ts", "kind": "file", "mode": "644", - "sha256": "2309c96dd45a03509df204803de9ecf0b5ff82fd488730f55ac5dd6a23b81dd8" + "sha256": "48fd54bd0cdb421badb58f9be2933a039fe3b9350bbe6191070c9f7bb0054670" }, { "path": "scripts/src/types.ts", diff --git a/skills/atlassian/codex/scripts/src/cli.ts b/skills/atlassian/codex/scripts/src/cli.ts index 68c1f87..a4f4fd0 100644 --- a/skills/atlassian/codex/scripts/src/cli.ts +++ b/skills/atlassian/codex/scripts/src/cli.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url"; import { Command } from "commander"; +import { resolveFormat } from "./command-helpers.js"; import { createConfluenceClient } from "./confluence.js"; import { loadConfig } from "./config.js"; import { readWorkspaceFile } from "./files.js"; @@ -11,7 +12,7 @@ import { runHealthCheck } from "./health.js"; import { createJiraClient } from "./jira.js"; import { writeOutput } from "./output.js"; import { runRawCommand } from "./raw.js"; -import type { FetchLike, OutputFormat, Writer } from "./types.js"; +import type { FetchLike, Writer } from "./types.js"; type CliContext = { cwd?: string; @@ -21,10 +22,6 @@ type CliContext = { stderr?: Writer; }; -function resolveFormat(format: string | undefined): OutputFormat { - return format === "text" ? "text" : "json"; -} - function createRuntime(context: CliContext) { const cwd = context.cwd ?? process.cwd(); const env = context.env ?? process.env; diff --git a/skills/atlassian/codex/scripts/src/command-helpers.ts b/skills/atlassian/codex/scripts/src/command-helpers.ts new file mode 100644 index 0000000..9ceca7c --- /dev/null +++ b/skills/atlassian/codex/scripts/src/command-helpers.ts @@ -0,0 +1,25 @@ +// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import type { CommandOutput, OutputFormat } from "./types.js"; + +/** + * Produce the standard dry-run response payload for write operations. + * + * Use this when `--dry-run` is passed to skip the actual API call and + * echo the pending request back to the caller. + * + * @example + * if (input.dryRun) return dryRunResponse(request); + */ +export function dryRunResponse(data: T): CommandOutput { + return { ok: true, dryRun: true, data }; +} + +/** + * Resolve the `--format` CLI option to a typed OutputFormat. + * + * Returns `"text"` only for the exact string `"text"`; + * all other values (including `undefined`) fall back to `"json"`. + */ +export function resolveFormat(format: string | undefined): OutputFormat { + return format === "text" ? "text" : "json"; +} diff --git a/skills/atlassian/codex/scripts/src/confluence.ts b/skills/atlassian/codex/scripts/src/confluence.ts index 25e027d..6ac333d 100644 --- a/skills/atlassian/codex/scripts/src/confluence.ts +++ b/skills/atlassian/codex/scripts/src/confluence.ts @@ -1,4 +1,5 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import { dryRunResponse } from "./command-helpers.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; @@ -178,13 +179,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, @@ -224,13 +219,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, @@ -267,13 +256,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, diff --git a/skills/atlassian/codex/scripts/src/jira.ts b/skills/atlassian/codex/scripts/src/jira.ts index af19e03..6773dff 100644 --- a/skills/atlassian/codex/scripts/src/jira.ts +++ b/skills/atlassian/codex/scripts/src/jira.ts @@ -1,5 +1,6 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. import { markdownToAdf } from "./adf.js"; +import { dryRunResponse } from "./command-helpers.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; @@ -162,13 +163,7 @@ export function createJiraClient(options: JiraClientOptions) { }, }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await send("POST", "/rest/api/3/issue", request.body); return { ok: true, data: raw }; @@ -193,13 +188,7 @@ export function createJiraClient(options: JiraClientOptions) { fields, }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); return { @@ -216,13 +205,7 @@ export function createJiraClient(options: JiraClientOptions) { body: markdownToAdf(input.body), }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); return { @@ -243,13 +226,7 @@ export function createJiraClient(options: JiraClientOptions) { }, ); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); return { diff --git a/skills/atlassian/codex/scripts/src/raw.ts b/skills/atlassian/codex/scripts/src/raw.ts index 620a259..43ed8de 100644 --- a/skills/atlassian/codex/scripts/src/raw.ts +++ b/skills/atlassian/codex/scripts/src/raw.ts @@ -1,4 +1,5 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import { dryRunResponse } from "./command-helpers.js"; import { readWorkspaceFile } from "./files.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; @@ -62,13 +63,7 @@ export async function runRawCommand( ...(body === undefined ? {} : { body }), }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const data = await sendJsonRequest({ config, diff --git a/skills/atlassian/cursor/.generated-manifest.json b/skills/atlassian/cursor/.generated-manifest.json index 7aa3f67..704e475 100644 --- a/skills/atlassian/cursor/.generated-manifest.json +++ b/skills/atlassian/cursor/.generated-manifest.json @@ -25,7 +25,13 @@ "path": "scripts/src/cli.ts", "kind": "file", "mode": "644", - "sha256": "5c4f4db76817fa9dbdae0fd0c75be302248d4b87fc0a53f6bd3c90407a75ae98" + "sha256": "90dcc029adf0625b86c5eec44c5c1fd11bbf95ffe1185016d139c8a6982d54ff" + }, + { + "path": "scripts/src/command-helpers.ts", + "kind": "file", + "mode": "644", + "sha256": "aa03d8d288c8c00485ea10d3b3a60804c1b9ee23ef265004e7912f3242dbcee7" }, { "path": "scripts/src/config.ts", @@ -37,7 +43,7 @@ "path": "scripts/src/confluence.ts", "kind": "file", "mode": "644", - "sha256": "709d5d61fdb14e37aa4eaa7175eb7f17f0ec661376c96071020fbc9574ddbb73" + "sha256": "28f65f280cd9b6119ce7eab583d0083231525ad6dc04b73389cb5dcbab5bf095" }, { "path": "scripts/src/files.ts", @@ -61,7 +67,7 @@ "path": "scripts/src/jira.ts", "kind": "file", "mode": "644", - "sha256": "485d8d618fe04eb1ce546c1694eadf15d867bc83c2a6f7df994688ab0335ea4f" + "sha256": "bec0e81a0424dd412c36988cef42c01a95f044ee8346ba626e7eb8bd79379f07" }, { "path": "scripts/src/output.ts", @@ -73,7 +79,7 @@ "path": "scripts/src/raw.ts", "kind": "file", "mode": "644", - "sha256": "2309c96dd45a03509df204803de9ecf0b5ff82fd488730f55ac5dd6a23b81dd8" + "sha256": "48fd54bd0cdb421badb58f9be2933a039fe3b9350bbe6191070c9f7bb0054670" }, { "path": "scripts/src/types.ts", diff --git a/skills/atlassian/cursor/scripts/src/cli.ts b/skills/atlassian/cursor/scripts/src/cli.ts index 68c1f87..a4f4fd0 100644 --- a/skills/atlassian/cursor/scripts/src/cli.ts +++ b/skills/atlassian/cursor/scripts/src/cli.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url"; import { Command } from "commander"; +import { resolveFormat } from "./command-helpers.js"; import { createConfluenceClient } from "./confluence.js"; import { loadConfig } from "./config.js"; import { readWorkspaceFile } from "./files.js"; @@ -11,7 +12,7 @@ import { runHealthCheck } from "./health.js"; import { createJiraClient } from "./jira.js"; import { writeOutput } from "./output.js"; import { runRawCommand } from "./raw.js"; -import type { FetchLike, OutputFormat, Writer } from "./types.js"; +import type { FetchLike, Writer } from "./types.js"; type CliContext = { cwd?: string; @@ -21,10 +22,6 @@ type CliContext = { stderr?: Writer; }; -function resolveFormat(format: string | undefined): OutputFormat { - return format === "text" ? "text" : "json"; -} - function createRuntime(context: CliContext) { const cwd = context.cwd ?? process.cwd(); const env = context.env ?? process.env; diff --git a/skills/atlassian/cursor/scripts/src/command-helpers.ts b/skills/atlassian/cursor/scripts/src/command-helpers.ts new file mode 100644 index 0000000..9ceca7c --- /dev/null +++ b/skills/atlassian/cursor/scripts/src/command-helpers.ts @@ -0,0 +1,25 @@ +// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import type { CommandOutput, OutputFormat } from "./types.js"; + +/** + * Produce the standard dry-run response payload for write operations. + * + * Use this when `--dry-run` is passed to skip the actual API call and + * echo the pending request back to the caller. + * + * @example + * if (input.dryRun) return dryRunResponse(request); + */ +export function dryRunResponse(data: T): CommandOutput { + return { ok: true, dryRun: true, data }; +} + +/** + * Resolve the `--format` CLI option to a typed OutputFormat. + * + * Returns `"text"` only for the exact string `"text"`; + * all other values (including `undefined`) fall back to `"json"`. + */ +export function resolveFormat(format: string | undefined): OutputFormat { + return format === "text" ? "text" : "json"; +} diff --git a/skills/atlassian/cursor/scripts/src/confluence.ts b/skills/atlassian/cursor/scripts/src/confluence.ts index 25e027d..6ac333d 100644 --- a/skills/atlassian/cursor/scripts/src/confluence.ts +++ b/skills/atlassian/cursor/scripts/src/confluence.ts @@ -1,4 +1,5 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import { dryRunResponse } from "./command-helpers.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; @@ -178,13 +179,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, @@ -224,13 +219,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, @@ -267,13 +256,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, diff --git a/skills/atlassian/cursor/scripts/src/jira.ts b/skills/atlassian/cursor/scripts/src/jira.ts index af19e03..6773dff 100644 --- a/skills/atlassian/cursor/scripts/src/jira.ts +++ b/skills/atlassian/cursor/scripts/src/jira.ts @@ -1,5 +1,6 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. import { markdownToAdf } from "./adf.js"; +import { dryRunResponse } from "./command-helpers.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; @@ -162,13 +163,7 @@ export function createJiraClient(options: JiraClientOptions) { }, }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await send("POST", "/rest/api/3/issue", request.body); return { ok: true, data: raw }; @@ -193,13 +188,7 @@ export function createJiraClient(options: JiraClientOptions) { fields, }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); return { @@ -216,13 +205,7 @@ export function createJiraClient(options: JiraClientOptions) { body: markdownToAdf(input.body), }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); return { @@ -243,13 +226,7 @@ export function createJiraClient(options: JiraClientOptions) { }, ); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); return { diff --git a/skills/atlassian/cursor/scripts/src/raw.ts b/skills/atlassian/cursor/scripts/src/raw.ts index 620a259..43ed8de 100644 --- a/skills/atlassian/cursor/scripts/src/raw.ts +++ b/skills/atlassian/cursor/scripts/src/raw.ts @@ -1,4 +1,5 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import { dryRunResponse } from "./command-helpers.js"; import { readWorkspaceFile } from "./files.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; @@ -62,13 +63,7 @@ export async function runRawCommand( ...(body === undefined ? {} : { body }), }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const data = await sendJsonRequest({ config, diff --git a/skills/atlassian/opencode/.generated-manifest.json b/skills/atlassian/opencode/.generated-manifest.json index 3888a55..439c89e 100644 --- a/skills/atlassian/opencode/.generated-manifest.json +++ b/skills/atlassian/opencode/.generated-manifest.json @@ -25,7 +25,13 @@ "path": "scripts/src/cli.ts", "kind": "file", "mode": "644", - "sha256": "5c4f4db76817fa9dbdae0fd0c75be302248d4b87fc0a53f6bd3c90407a75ae98" + "sha256": "90dcc029adf0625b86c5eec44c5c1fd11bbf95ffe1185016d139c8a6982d54ff" + }, + { + "path": "scripts/src/command-helpers.ts", + "kind": "file", + "mode": "644", + "sha256": "aa03d8d288c8c00485ea10d3b3a60804c1b9ee23ef265004e7912f3242dbcee7" }, { "path": "scripts/src/config.ts", @@ -37,7 +43,7 @@ "path": "scripts/src/confluence.ts", "kind": "file", "mode": "644", - "sha256": "709d5d61fdb14e37aa4eaa7175eb7f17f0ec661376c96071020fbc9574ddbb73" + "sha256": "28f65f280cd9b6119ce7eab583d0083231525ad6dc04b73389cb5dcbab5bf095" }, { "path": "scripts/src/files.ts", @@ -61,7 +67,7 @@ "path": "scripts/src/jira.ts", "kind": "file", "mode": "644", - "sha256": "485d8d618fe04eb1ce546c1694eadf15d867bc83c2a6f7df994688ab0335ea4f" + "sha256": "bec0e81a0424dd412c36988cef42c01a95f044ee8346ba626e7eb8bd79379f07" }, { "path": "scripts/src/output.ts", @@ -73,7 +79,7 @@ "path": "scripts/src/raw.ts", "kind": "file", "mode": "644", - "sha256": "2309c96dd45a03509df204803de9ecf0b5ff82fd488730f55ac5dd6a23b81dd8" + "sha256": "48fd54bd0cdb421badb58f9be2933a039fe3b9350bbe6191070c9f7bb0054670" }, { "path": "scripts/src/types.ts", diff --git a/skills/atlassian/opencode/scripts/src/cli.ts b/skills/atlassian/opencode/scripts/src/cli.ts index 68c1f87..a4f4fd0 100644 --- a/skills/atlassian/opencode/scripts/src/cli.ts +++ b/skills/atlassian/opencode/scripts/src/cli.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url"; import { Command } from "commander"; +import { resolveFormat } from "./command-helpers.js"; import { createConfluenceClient } from "./confluence.js"; import { loadConfig } from "./config.js"; import { readWorkspaceFile } from "./files.js"; @@ -11,7 +12,7 @@ import { runHealthCheck } from "./health.js"; import { createJiraClient } from "./jira.js"; import { writeOutput } from "./output.js"; import { runRawCommand } from "./raw.js"; -import type { FetchLike, OutputFormat, Writer } from "./types.js"; +import type { FetchLike, Writer } from "./types.js"; type CliContext = { cwd?: string; @@ -21,10 +22,6 @@ type CliContext = { stderr?: Writer; }; -function resolveFormat(format: string | undefined): OutputFormat { - return format === "text" ? "text" : "json"; -} - function createRuntime(context: CliContext) { const cwd = context.cwd ?? process.cwd(); const env = context.env ?? process.env; diff --git a/skills/atlassian/opencode/scripts/src/command-helpers.ts b/skills/atlassian/opencode/scripts/src/command-helpers.ts new file mode 100644 index 0000000..9ceca7c --- /dev/null +++ b/skills/atlassian/opencode/scripts/src/command-helpers.ts @@ -0,0 +1,25 @@ +// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import type { CommandOutput, OutputFormat } from "./types.js"; + +/** + * Produce the standard dry-run response payload for write operations. + * + * Use this when `--dry-run` is passed to skip the actual API call and + * echo the pending request back to the caller. + * + * @example + * if (input.dryRun) return dryRunResponse(request); + */ +export function dryRunResponse(data: T): CommandOutput { + return { ok: true, dryRun: true, data }; +} + +/** + * Resolve the `--format` CLI option to a typed OutputFormat. + * + * Returns `"text"` only for the exact string `"text"`; + * all other values (including `undefined`) fall back to `"json"`. + */ +export function resolveFormat(format: string | undefined): OutputFormat { + return format === "text" ? "text" : "json"; +} diff --git a/skills/atlassian/opencode/scripts/src/confluence.ts b/skills/atlassian/opencode/scripts/src/confluence.ts index 25e027d..6ac333d 100644 --- a/skills/atlassian/opencode/scripts/src/confluence.ts +++ b/skills/atlassian/opencode/scripts/src/confluence.ts @@ -1,4 +1,5 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import { dryRunResponse } from "./command-helpers.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; @@ -178,13 +179,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, @@ -224,13 +219,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, @@ -267,13 +256,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, diff --git a/skills/atlassian/opencode/scripts/src/jira.ts b/skills/atlassian/opencode/scripts/src/jira.ts index af19e03..6773dff 100644 --- a/skills/atlassian/opencode/scripts/src/jira.ts +++ b/skills/atlassian/opencode/scripts/src/jira.ts @@ -1,5 +1,6 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. import { markdownToAdf } from "./adf.js"; +import { dryRunResponse } from "./command-helpers.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; @@ -162,13 +163,7 @@ export function createJiraClient(options: JiraClientOptions) { }, }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await send("POST", "/rest/api/3/issue", request.body); return { ok: true, data: raw }; @@ -193,13 +188,7 @@ export function createJiraClient(options: JiraClientOptions) { fields, }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); return { @@ -216,13 +205,7 @@ export function createJiraClient(options: JiraClientOptions) { body: markdownToAdf(input.body), }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); return { @@ -243,13 +226,7 @@ export function createJiraClient(options: JiraClientOptions) { }, ); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); return { diff --git a/skills/atlassian/opencode/scripts/src/raw.ts b/skills/atlassian/opencode/scripts/src/raw.ts index 620a259..43ed8de 100644 --- a/skills/atlassian/opencode/scripts/src/raw.ts +++ b/skills/atlassian/opencode/scripts/src/raw.ts @@ -1,4 +1,5 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import { dryRunResponse } from "./command-helpers.js"; import { readWorkspaceFile } from "./files.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; @@ -62,13 +63,7 @@ export async function runRawCommand( ...(body === undefined ? {} : { body }), }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const data = await sendJsonRequest({ config, diff --git a/skills/atlassian/pi/.generated-manifest.json b/skills/atlassian/pi/.generated-manifest.json index 4fdd605..b07d803 100644 --- a/skills/atlassian/pi/.generated-manifest.json +++ b/skills/atlassian/pi/.generated-manifest.json @@ -25,7 +25,13 @@ "path": "scripts/src/cli.ts", "kind": "file", "mode": "644", - "sha256": "5c4f4db76817fa9dbdae0fd0c75be302248d4b87fc0a53f6bd3c90407a75ae98" + "sha256": "90dcc029adf0625b86c5eec44c5c1fd11bbf95ffe1185016d139c8a6982d54ff" + }, + { + "path": "scripts/src/command-helpers.ts", + "kind": "file", + "mode": "644", + "sha256": "aa03d8d288c8c00485ea10d3b3a60804c1b9ee23ef265004e7912f3242dbcee7" }, { "path": "scripts/src/config.ts", @@ -37,7 +43,7 @@ "path": "scripts/src/confluence.ts", "kind": "file", "mode": "644", - "sha256": "709d5d61fdb14e37aa4eaa7175eb7f17f0ec661376c96071020fbc9574ddbb73" + "sha256": "28f65f280cd9b6119ce7eab583d0083231525ad6dc04b73389cb5dcbab5bf095" }, { "path": "scripts/src/files.ts", @@ -61,7 +67,7 @@ "path": "scripts/src/jira.ts", "kind": "file", "mode": "644", - "sha256": "485d8d618fe04eb1ce546c1694eadf15d867bc83c2a6f7df994688ab0335ea4f" + "sha256": "bec0e81a0424dd412c36988cef42c01a95f044ee8346ba626e7eb8bd79379f07" }, { "path": "scripts/src/output.ts", @@ -73,7 +79,7 @@ "path": "scripts/src/raw.ts", "kind": "file", "mode": "644", - "sha256": "2309c96dd45a03509df204803de9ecf0b5ff82fd488730f55ac5dd6a23b81dd8" + "sha256": "48fd54bd0cdb421badb58f9be2933a039fe3b9350bbe6191070c9f7bb0054670" }, { "path": "scripts/src/types.ts", diff --git a/skills/atlassian/pi/scripts/src/cli.ts b/skills/atlassian/pi/scripts/src/cli.ts index 68c1f87..a4f4fd0 100644 --- a/skills/atlassian/pi/scripts/src/cli.ts +++ b/skills/atlassian/pi/scripts/src/cli.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url"; import { Command } from "commander"; +import { resolveFormat } from "./command-helpers.js"; import { createConfluenceClient } from "./confluence.js"; import { loadConfig } from "./config.js"; import { readWorkspaceFile } from "./files.js"; @@ -11,7 +12,7 @@ import { runHealthCheck } from "./health.js"; import { createJiraClient } from "./jira.js"; import { writeOutput } from "./output.js"; import { runRawCommand } from "./raw.js"; -import type { FetchLike, OutputFormat, Writer } from "./types.js"; +import type { FetchLike, Writer } from "./types.js"; type CliContext = { cwd?: string; @@ -21,10 +22,6 @@ type CliContext = { stderr?: Writer; }; -function resolveFormat(format: string | undefined): OutputFormat { - return format === "text" ? "text" : "json"; -} - function createRuntime(context: CliContext) { const cwd = context.cwd ?? process.cwd(); const env = context.env ?? process.env; diff --git a/skills/atlassian/pi/scripts/src/command-helpers.ts b/skills/atlassian/pi/scripts/src/command-helpers.ts new file mode 100644 index 0000000..9ceca7c --- /dev/null +++ b/skills/atlassian/pi/scripts/src/command-helpers.ts @@ -0,0 +1,25 @@ +// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import type { CommandOutput, OutputFormat } from "./types.js"; + +/** + * Produce the standard dry-run response payload for write operations. + * + * Use this when `--dry-run` is passed to skip the actual API call and + * echo the pending request back to the caller. + * + * @example + * if (input.dryRun) return dryRunResponse(request); + */ +export function dryRunResponse(data: T): CommandOutput { + return { ok: true, dryRun: true, data }; +} + +/** + * Resolve the `--format` CLI option to a typed OutputFormat. + * + * Returns `"text"` only for the exact string `"text"`; + * all other values (including `undefined`) fall back to `"json"`. + */ +export function resolveFormat(format: string | undefined): OutputFormat { + return format === "text" ? "text" : "json"; +} diff --git a/skills/atlassian/pi/scripts/src/confluence.ts b/skills/atlassian/pi/scripts/src/confluence.ts index 25e027d..6ac333d 100644 --- a/skills/atlassian/pi/scripts/src/confluence.ts +++ b/skills/atlassian/pi/scripts/src/confluence.ts @@ -1,4 +1,5 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import { dryRunResponse } from "./command-helpers.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; @@ -178,13 +179,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, @@ -224,13 +219,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, @@ -267,13 +256,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, diff --git a/skills/atlassian/pi/scripts/src/jira.ts b/skills/atlassian/pi/scripts/src/jira.ts index af19e03..6773dff 100644 --- a/skills/atlassian/pi/scripts/src/jira.ts +++ b/skills/atlassian/pi/scripts/src/jira.ts @@ -1,5 +1,6 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. import { markdownToAdf } from "./adf.js"; +import { dryRunResponse } from "./command-helpers.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; @@ -162,13 +163,7 @@ export function createJiraClient(options: JiraClientOptions) { }, }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await send("POST", "/rest/api/3/issue", request.body); return { ok: true, data: raw }; @@ -193,13 +188,7 @@ export function createJiraClient(options: JiraClientOptions) { fields, }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); return { @@ -216,13 +205,7 @@ export function createJiraClient(options: JiraClientOptions) { body: markdownToAdf(input.body), }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); return { @@ -243,13 +226,7 @@ export function createJiraClient(options: JiraClientOptions) { }, ); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); return { diff --git a/skills/atlassian/pi/scripts/src/raw.ts b/skills/atlassian/pi/scripts/src/raw.ts index 620a259..43ed8de 100644 --- a/skills/atlassian/pi/scripts/src/raw.ts +++ b/skills/atlassian/pi/scripts/src/raw.ts @@ -1,4 +1,5 @@ // ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/atlassian/shared/scripts/ and run `pnpm run sync:pi`. +import { dryRunResponse } from "./command-helpers.js"; import { readWorkspaceFile } from "./files.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; @@ -62,13 +63,7 @@ export async function runRawCommand( ...(body === undefined ? {} : { body }), }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const data = await sendJsonRequest({ config, diff --git a/skills/atlassian/shared/scripts/src/cli.ts b/skills/atlassian/shared/scripts/src/cli.ts index 6012b99..6ef4df2 100644 --- a/skills/atlassian/shared/scripts/src/cli.ts +++ b/skills/atlassian/shared/scripts/src/cli.ts @@ -3,6 +3,7 @@ import { pathToFileURL } from "node:url"; import { Command } from "commander"; +import { resolveFormat } from "./command-helpers.js"; import { createConfluenceClient } from "./confluence.js"; import { loadConfig } from "./config.js"; import { readWorkspaceFile } from "./files.js"; @@ -10,7 +11,7 @@ import { runHealthCheck } from "./health.js"; import { createJiraClient } from "./jira.js"; import { writeOutput } from "./output.js"; import { runRawCommand } from "./raw.js"; -import type { FetchLike, OutputFormat, Writer } from "./types.js"; +import type { FetchLike, Writer } from "./types.js"; type CliContext = { cwd?: string; @@ -20,10 +21,6 @@ type CliContext = { stderr?: Writer; }; -function resolveFormat(format: string | undefined): OutputFormat { - return format === "text" ? "text" : "json"; -} - function createRuntime(context: CliContext) { const cwd = context.cwd ?? process.cwd(); const env = context.env ?? process.env; diff --git a/skills/atlassian/shared/scripts/src/command-helpers.ts b/skills/atlassian/shared/scripts/src/command-helpers.ts new file mode 100644 index 0000000..723b187 --- /dev/null +++ b/skills/atlassian/shared/scripts/src/command-helpers.ts @@ -0,0 +1,24 @@ +import type { CommandOutput, OutputFormat } from "./types.js"; + +/** + * Produce the standard dry-run response payload for write operations. + * + * Use this when `--dry-run` is passed to skip the actual API call and + * echo the pending request back to the caller. + * + * @example + * if (input.dryRun) return dryRunResponse(request); + */ +export function dryRunResponse(data: T): CommandOutput { + return { ok: true, dryRun: true, data }; +} + +/** + * Resolve the `--format` CLI option to a typed OutputFormat. + * + * Returns `"text"` only for the exact string `"text"`; + * all other values (including `undefined`) fall back to `"json"`. + */ +export function resolveFormat(format: string | undefined): OutputFormat { + return format === "text" ? "text" : "json"; +} diff --git a/skills/atlassian/shared/scripts/src/confluence.ts b/skills/atlassian/shared/scripts/src/confluence.ts index f22d66d..258eb0d 100644 --- a/skills/atlassian/shared/scripts/src/confluence.ts +++ b/skills/atlassian/shared/scripts/src/confluence.ts @@ -1,3 +1,4 @@ +import { dryRunResponse } from "./command-helpers.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; @@ -177,13 +178,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, @@ -223,13 +218,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, @@ -266,13 +255,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) { }, }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await sendJsonRequest({ config, diff --git a/skills/atlassian/shared/scripts/src/jira.ts b/skills/atlassian/shared/scripts/src/jira.ts index 5cf3a6e..065666c 100644 --- a/skills/atlassian/shared/scripts/src/jira.ts +++ b/skills/atlassian/shared/scripts/src/jira.ts @@ -1,4 +1,5 @@ import { markdownToAdf } from "./adf.js"; +import { dryRunResponse } from "./command-helpers.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; @@ -161,13 +162,7 @@ export function createJiraClient(options: JiraClientOptions) { }, }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await send("POST", "/rest/api/3/issue", request.body); return { ok: true, data: raw }; @@ -192,13 +187,7 @@ export function createJiraClient(options: JiraClientOptions) { fields, }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); return { @@ -215,13 +204,7 @@ export function createJiraClient(options: JiraClientOptions) { body: markdownToAdf(input.body), }); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); return { @@ -242,13 +225,7 @@ export function createJiraClient(options: JiraClientOptions) { }, ); - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); return { diff --git a/skills/atlassian/shared/scripts/src/raw.ts b/skills/atlassian/shared/scripts/src/raw.ts index 8e11793..de31354 100644 --- a/skills/atlassian/shared/scripts/src/raw.ts +++ b/skills/atlassian/shared/scripts/src/raw.ts @@ -1,3 +1,4 @@ +import { dryRunResponse } from "./command-helpers.js"; import { readWorkspaceFile } from "./files.js"; import { sendJsonRequest } from "./http.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; @@ -61,13 +62,7 @@ export async function runRawCommand( ...(body === undefined ? {} : { body }), }; - if (input.dryRun) { - return { - ok: true, - dryRun: true, - data: request, - }; - } + if (input.dryRun) return dryRunResponse(request); const data = await sendJsonRequest({ config, diff --git a/skills/atlassian/shared/scripts/tests/command-helpers.test.ts b/skills/atlassian/shared/scripts/tests/command-helpers.test.ts new file mode 100644 index 0000000..754a148 --- /dev/null +++ b/skills/atlassian/shared/scripts/tests/command-helpers.test.ts @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { dryRunResponse, resolveFormat } from "../src/command-helpers.js"; + +// ── dryRunResponse ──────────────────────────────────────────────────────── + +test("dryRunResponse wraps data with ok:true and dryRun:true", () => { + const data = { method: "POST", url: "https://example.com/api/v2/pages" }; + const result = dryRunResponse(data); + assert.equal(result.ok, true); + assert.equal(result.dryRun, true); + assert.deepEqual(result.data, data); +}); + +test("dryRunResponse preserves the exact data reference", () => { + const data = { nested: { key: "value" } }; + const result = dryRunResponse(data); + assert.strictEqual(result.data, data); +}); + +test("dryRunResponse works with primitive data", () => { + const result = dryRunResponse("dry-run-string"); + assert.equal(result.ok, true); + assert.equal(result.dryRun, true); + assert.equal(result.data, "dry-run-string"); +}); + +// ── resolveFormat ───────────────────────────────────────────────────────── + +test("resolveFormat returns json by default for undefined", () => { + assert.equal(resolveFormat(undefined), "json"); +}); + +test("resolveFormat returns json for unrecognised values", () => { + assert.equal(resolveFormat("xml"), "json"); + assert.equal(resolveFormat(""), "json"); + assert.equal(resolveFormat("TEXT"), "json"); +}); + +test("resolveFormat returns text only for the exact string 'text'", () => { + assert.equal(resolveFormat("text"), "text"); +}); diff --git a/skills/web-automation/claude-code/.generated-manifest.json b/skills/web-automation/claude-code/.generated-manifest.json index 964cfd2..f6a9d6e 100644 --- a/skills/web-automation/claude-code/.generated-manifest.json +++ b/skills/web-automation/claude-code/.generated-manifest.json @@ -7,13 +7,13 @@ "path": "scripts/auth.ts", "kind": "file", "mode": "644", - "sha256": "ce0a8aae0bc41b86e11aab51cc0e0cfa484a1934807f147c05c9bd38d416c066" + "sha256": "c0940f452437b05b95e58a9a7ab265fb50aa412bd672e82fedd6a37cbfb3d505" }, { "path": "scripts/browse.ts", "kind": "file", "mode": "644", - "sha256": "42da9cdc6806b8d7d8d814952ad9540033b6c6a4cbe9844ada328b2ceace67c9" + "sha256": "d7e4b4c50116032e5a00f90bca27e069dfc5bbf6eeb06ec8f8edc9e5a9792ab8" }, { "path": "scripts/check-install.js", @@ -31,7 +31,13 @@ "path": "scripts/flow.ts", "kind": "file", "mode": "644", - "sha256": "b1c256bf6a206473512a4c0555c891893a48025529da282fa6cd07e68ad3d051" + "sha256": "94f3e7987cab253dc3c9e80656a11759fada13b3915608bff7ae08418602f366" + }, + { + "path": "scripts/lib/browser.ts", + "kind": "file", + "mode": "644", + "sha256": "879b5f883ff1f888d45ed20be05c2d9bc3d6fe5305a1972b7d49a7e6c0e24934" }, { "path": "scripts/package.json", @@ -49,7 +55,7 @@ "path": "scripts/scan-local-app.ts", "kind": "file", "mode": "644", - "sha256": "3f42f9bb2d355fefc8645d2b2acfa3107bd87f9c2579b2631c94132bed0abea4" + "sha256": "9e1818c254a633e087715609152936dcb3613a0aa724d40a8a13460510691dc7" }, { "path": "scripts/scrape.ts", @@ -79,7 +85,7 @@ "path": "scripts/tsconfig.json", "kind": "file", "mode": "644", - "sha256": "5f9a83c8caab167eb20defbb5afde58f2bb573a300af99654997dcb3372408e0" + "sha256": "e5f22d72266068cf410976c880511f2ec1875445256e11739a5e1de6ffedf38d" }, { "path": "scripts/turndown-plugin-gfm.d.ts", diff --git a/skills/web-automation/claude-code/scripts/auth.ts b/skills/web-automation/claude-code/scripts/auth.ts index 272ee6d..b06e846 100644 --- a/skills/web-automation/claude-code/scripts/auth.ts +++ b/skills/web-automation/claude-code/scripts/auth.ts @@ -11,7 +11,7 @@ * npx tsx auth.ts --url "https://example.com" --type auto */ -import { getPage, launchBrowser } from './browse.js'; +import { getPage, launchBrowser } from './lib/browser.js'; import parseArgs from 'minimist'; import type { Page, BrowserContext } from 'playwright-core'; import { createInterface } from 'readline'; diff --git a/skills/web-automation/claude-code/scripts/browse.ts b/skills/web-automation/claude-code/scripts/browse.ts index 3d27c9e..e270db8 100644 --- a/skills/web-automation/claude-code/scripts/browse.ts +++ b/skills/web-automation/claude-code/scripts/browse.ts @@ -10,12 +10,13 @@ * npx tsx browse.ts --url "https://example.com" --headless false --wait 5000 */ -import { launchPersistentContext } from 'cloakbrowser'; -import { homedir } from 'os'; -import { join } from 'path'; -import { existsSync, mkdirSync } from 'fs'; import parseArgs from 'minimist'; -import type { Page, BrowserContext } from 'playwright-core'; +import type { BrowserContext } from 'playwright-core'; + +import { getProfilePath, launchBrowser, getPage } from './lib/browser.js'; + +// Re-export shared helpers so existing imports of browse.ts continue to work. +export { getProfilePath, launchBrowser, getPage }; interface BrowseOptions { url: string; @@ -37,36 +38,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -const getProfilePath = (): string => { - const customPath = process.env.CLOAKBROWSER_PROFILE_PATH; - if (customPath) return customPath; - - const profileDir = join(homedir(), '.cloakbrowser-profile'); - if (!existsSync(profileDir)) { - mkdirSync(profileDir, { recursive: true }); - } - return profileDir; -}; - -export async function launchBrowser(options: { - headless?: boolean; -}): Promise { - const profilePath = getProfilePath(); - const envHeadless = process.env.CLOAKBROWSER_HEADLESS; - const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true); - - console.log(`Using profile: ${profilePath}`); - console.log(`Headless mode: ${headless}`); - - const context = await launchPersistentContext({ - userDataDir: profilePath, - headless, - humanize: true, - }); - - return context; -} - export async function browse(options: BrowseOptions): Promise { const browser = await launchBrowser({ headless: options.headless }); const page = browser.pages()[0] || await browser.newPage(); @@ -112,14 +83,6 @@ export async function browse(options: BrowseOptions): Promise { } } -export async function getPage(options?: { - headless?: boolean; -}): Promise<{ page: Page; browser: BrowserContext }> { - const browser = await launchBrowser({ headless: options?.headless }); - const page = browser.pages()[0] || await browser.newPage(); - return { page, browser }; -} - async function main() { const args = parseArgs(process.argv.slice(2), { string: ['url', 'output'], diff --git a/skills/web-automation/claude-code/scripts/flow.ts b/skills/web-automation/claude-code/scripts/flow.ts index dfe2d8c..6b8b5e6 100644 --- a/skills/web-automation/claude-code/scripts/flow.ts +++ b/skills/web-automation/claude-code/scripts/flow.ts @@ -3,7 +3,7 @@ import parseArgs from 'minimist'; import type { Page } from 'playwright-core'; -import { launchBrowser } from './browse'; +import { launchBrowser } from './lib/browser.js'; type Step = | { action: 'goto'; url: string } diff --git a/skills/web-automation/claude-code/scripts/lib/browser.ts b/skills/web-automation/claude-code/scripts/lib/browser.ts new file mode 100644 index 0000000..5723bad --- /dev/null +++ b/skills/web-automation/claude-code/scripts/lib/browser.ts @@ -0,0 +1,76 @@ +// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`. +/** + * Shared browser-launch and profile helpers for web-automation scripts. + * + * Centralises the three reusable primitives that every command entry point + * needs: + * - getProfilePath() — resolve the persistent CloakBrowser profile dir + * - launchBrowser() — launch a CloakBrowser persistent context + * - getPage() — get a ready Page + BrowserContext pair + * + * All command entry points (auth.ts, browse.ts, flow.ts, scan-local-app.ts) + * import from here instead of duplicating these bodies. + */ + +import { launchPersistentContext } from 'cloakbrowser'; +import { existsSync, mkdirSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import type { BrowserContext, Page } from 'playwright-core'; + +/** + * Return the path to the persistent CloakBrowser profile directory. + * + * Uses `CLOAKBROWSER_PROFILE_PATH` env var when set; otherwise defaults to + * `~/.cloakbrowser-profile/` and creates it if it does not exist. + */ +export function getProfilePath(): string { + const customPath = process.env.CLOAKBROWSER_PROFILE_PATH; + if (customPath) return customPath; + + const profileDir = join(homedir(), '.cloakbrowser-profile'); + if (!existsSync(profileDir)) { + mkdirSync(profileDir, { recursive: true }); + } + return profileDir; +} + +/** + * Launch a CloakBrowser persistent context with the shared profile. + * + * Headless mode is resolved in order: + * 1. `options.headless` (explicit caller preference) + * 2. `CLOAKBROWSER_HEADLESS` env var + * 3. `true` (safe default) + */ +export async function launchBrowser(options: { + headless?: boolean; +}): Promise { + const profilePath = getProfilePath(); + const envHeadless = process.env.CLOAKBROWSER_HEADLESS; + const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true); + + console.log(`Using profile: ${profilePath}`); + console.log(`Headless mode: ${headless}`); + + const context = await launchPersistentContext({ + userDataDir: profilePath, + headless, + humanize: true, + }); + + return context; +} + +/** + * Return a ready `{ page, browser }` pair using the shared persistent profile. + * + * Re-uses the first existing page or opens a new one if the context is empty. + */ +export async function getPage(options?: { + headless?: boolean; +}): Promise<{ page: Page; browser: BrowserContext }> { + const browser = await launchBrowser({ headless: options?.headless }); + const page = browser.pages()[0] || (await browser.newPage()); + return { page, browser }; +} diff --git a/skills/web-automation/claude-code/scripts/scan-local-app.ts b/skills/web-automation/claude-code/scripts/scan-local-app.ts index 00e213e..cf8d190 100644 --- a/skills/web-automation/claude-code/scripts/scan-local-app.ts +++ b/skills/web-automation/claude-code/scripts/scan-local-app.ts @@ -3,7 +3,8 @@ import { mkdirSync, writeFileSync } from 'fs'; import { dirname, resolve } from 'path'; -import { getPage } from './browse.js'; +import type { Page } from 'playwright-core'; +import { getPage } from './lib/browser.js'; type NavResult = { requestedUrl: string; @@ -40,30 +41,34 @@ function getRoutes(baseUrl: string): string[] { return [baseUrl]; } -async function gotoWithStatus(page: any, url: string): Promise { +type GotoError = { error: unknown }; + +async function gotoWithStatus(page: Page, url: string): Promise { const response = await page .goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) - .catch((error: unknown) => ({ error })); + .catch((error: unknown): GotoError => ({ error })); - if (response?.error) { + if (response !== null && response !== undefined && 'error' in response) { + const gotoError = response as GotoError; return { requestedUrl: url, url: page.url(), status: null, title: await page.title().catch(() => ''), - error: String(response.error), + error: String(gotoError.error), }; } + const httpResponse = response as Awaited>; return { requestedUrl: url, url: page.url(), - status: response ? response.status() : null, + status: httpResponse ? httpResponse.status() : null, title: await page.title().catch(() => ''), }; } -async function textOrNull(page: any, selector: string): Promise { +async function textOrNull(page: Page, selector: string): Promise { const locator = page.locator(selector).first(); try { if ((await locator.count()) === 0) return null; @@ -74,7 +79,7 @@ async function textOrNull(page: any, selector: string): Promise { } } -async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { +async function loginIfConfigured(page: Page, baseUrl: string, lines: string[]) { const loginPath = env('SCAN_LOGIN_PATH'); const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); @@ -110,7 +115,7 @@ async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { lines.push(''); } -async function checkRoutes(page: any, baseUrl: string, lines: string[]) { +async function checkRoutes(page: Page, baseUrl: string, lines: string[]) { const routes = getRoutes(baseUrl); const routeChecks: RouteCheck[] = []; diff --git a/skills/web-automation/claude-code/scripts/tsconfig.json b/skills/web-automation/claude-code/scripts/tsconfig.json index 4c23583..689017a 100644 --- a/skills/web-automation/claude-code/scripts/tsconfig.json +++ b/skills/web-automation/claude-code/scripts/tsconfig.json @@ -11,6 +11,6 @@ "outDir": "./dist", "rootDir": "." }, - "include": ["*.ts"], + "include": ["*.ts", "lib/**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/skills/web-automation/codex/.generated-manifest.json b/skills/web-automation/codex/.generated-manifest.json index 71b1029..173482a 100644 --- a/skills/web-automation/codex/.generated-manifest.json +++ b/skills/web-automation/codex/.generated-manifest.json @@ -7,13 +7,13 @@ "path": "scripts/auth.ts", "kind": "file", "mode": "644", - "sha256": "ce0a8aae0bc41b86e11aab51cc0e0cfa484a1934807f147c05c9bd38d416c066" + "sha256": "c0940f452437b05b95e58a9a7ab265fb50aa412bd672e82fedd6a37cbfb3d505" }, { "path": "scripts/browse.ts", "kind": "file", "mode": "644", - "sha256": "42da9cdc6806b8d7d8d814952ad9540033b6c6a4cbe9844ada328b2ceace67c9" + "sha256": "d7e4b4c50116032e5a00f90bca27e069dfc5bbf6eeb06ec8f8edc9e5a9792ab8" }, { "path": "scripts/check-install.js", @@ -31,7 +31,13 @@ "path": "scripts/flow.ts", "kind": "file", "mode": "644", - "sha256": "b1c256bf6a206473512a4c0555c891893a48025529da282fa6cd07e68ad3d051" + "sha256": "94f3e7987cab253dc3c9e80656a11759fada13b3915608bff7ae08418602f366" + }, + { + "path": "scripts/lib/browser.ts", + "kind": "file", + "mode": "644", + "sha256": "879b5f883ff1f888d45ed20be05c2d9bc3d6fe5305a1972b7d49a7e6c0e24934" }, { "path": "scripts/package.json", @@ -49,7 +55,7 @@ "path": "scripts/scan-local-app.ts", "kind": "file", "mode": "644", - "sha256": "3f42f9bb2d355fefc8645d2b2acfa3107bd87f9c2579b2631c94132bed0abea4" + "sha256": "9e1818c254a633e087715609152936dcb3613a0aa724d40a8a13460510691dc7" }, { "path": "scripts/scrape.ts", @@ -79,7 +85,7 @@ "path": "scripts/tsconfig.json", "kind": "file", "mode": "644", - "sha256": "5f9a83c8caab167eb20defbb5afde58f2bb573a300af99654997dcb3372408e0" + "sha256": "e5f22d72266068cf410976c880511f2ec1875445256e11739a5e1de6ffedf38d" }, { "path": "scripts/turndown-plugin-gfm.d.ts", diff --git a/skills/web-automation/codex/scripts/auth.ts b/skills/web-automation/codex/scripts/auth.ts index 272ee6d..b06e846 100644 --- a/skills/web-automation/codex/scripts/auth.ts +++ b/skills/web-automation/codex/scripts/auth.ts @@ -11,7 +11,7 @@ * npx tsx auth.ts --url "https://example.com" --type auto */ -import { getPage, launchBrowser } from './browse.js'; +import { getPage, launchBrowser } from './lib/browser.js'; import parseArgs from 'minimist'; import type { Page, BrowserContext } from 'playwright-core'; import { createInterface } from 'readline'; diff --git a/skills/web-automation/codex/scripts/browse.ts b/skills/web-automation/codex/scripts/browse.ts index 3d27c9e..e270db8 100644 --- a/skills/web-automation/codex/scripts/browse.ts +++ b/skills/web-automation/codex/scripts/browse.ts @@ -10,12 +10,13 @@ * npx tsx browse.ts --url "https://example.com" --headless false --wait 5000 */ -import { launchPersistentContext } from 'cloakbrowser'; -import { homedir } from 'os'; -import { join } from 'path'; -import { existsSync, mkdirSync } from 'fs'; import parseArgs from 'minimist'; -import type { Page, BrowserContext } from 'playwright-core'; +import type { BrowserContext } from 'playwright-core'; + +import { getProfilePath, launchBrowser, getPage } from './lib/browser.js'; + +// Re-export shared helpers so existing imports of browse.ts continue to work. +export { getProfilePath, launchBrowser, getPage }; interface BrowseOptions { url: string; @@ -37,36 +38,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -const getProfilePath = (): string => { - const customPath = process.env.CLOAKBROWSER_PROFILE_PATH; - if (customPath) return customPath; - - const profileDir = join(homedir(), '.cloakbrowser-profile'); - if (!existsSync(profileDir)) { - mkdirSync(profileDir, { recursive: true }); - } - return profileDir; -}; - -export async function launchBrowser(options: { - headless?: boolean; -}): Promise { - const profilePath = getProfilePath(); - const envHeadless = process.env.CLOAKBROWSER_HEADLESS; - const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true); - - console.log(`Using profile: ${profilePath}`); - console.log(`Headless mode: ${headless}`); - - const context = await launchPersistentContext({ - userDataDir: profilePath, - headless, - humanize: true, - }); - - return context; -} - export async function browse(options: BrowseOptions): Promise { const browser = await launchBrowser({ headless: options.headless }); const page = browser.pages()[0] || await browser.newPage(); @@ -112,14 +83,6 @@ export async function browse(options: BrowseOptions): Promise { } } -export async function getPage(options?: { - headless?: boolean; -}): Promise<{ page: Page; browser: BrowserContext }> { - const browser = await launchBrowser({ headless: options?.headless }); - const page = browser.pages()[0] || await browser.newPage(); - return { page, browser }; -} - async function main() { const args = parseArgs(process.argv.slice(2), { string: ['url', 'output'], diff --git a/skills/web-automation/codex/scripts/flow.ts b/skills/web-automation/codex/scripts/flow.ts index dfe2d8c..6b8b5e6 100644 --- a/skills/web-automation/codex/scripts/flow.ts +++ b/skills/web-automation/codex/scripts/flow.ts @@ -3,7 +3,7 @@ import parseArgs from 'minimist'; import type { Page } from 'playwright-core'; -import { launchBrowser } from './browse'; +import { launchBrowser } from './lib/browser.js'; type Step = | { action: 'goto'; url: string } diff --git a/skills/web-automation/codex/scripts/lib/browser.ts b/skills/web-automation/codex/scripts/lib/browser.ts new file mode 100644 index 0000000..5723bad --- /dev/null +++ b/skills/web-automation/codex/scripts/lib/browser.ts @@ -0,0 +1,76 @@ +// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`. +/** + * Shared browser-launch and profile helpers for web-automation scripts. + * + * Centralises the three reusable primitives that every command entry point + * needs: + * - getProfilePath() — resolve the persistent CloakBrowser profile dir + * - launchBrowser() — launch a CloakBrowser persistent context + * - getPage() — get a ready Page + BrowserContext pair + * + * All command entry points (auth.ts, browse.ts, flow.ts, scan-local-app.ts) + * import from here instead of duplicating these bodies. + */ + +import { launchPersistentContext } from 'cloakbrowser'; +import { existsSync, mkdirSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import type { BrowserContext, Page } from 'playwright-core'; + +/** + * Return the path to the persistent CloakBrowser profile directory. + * + * Uses `CLOAKBROWSER_PROFILE_PATH` env var when set; otherwise defaults to + * `~/.cloakbrowser-profile/` and creates it if it does not exist. + */ +export function getProfilePath(): string { + const customPath = process.env.CLOAKBROWSER_PROFILE_PATH; + if (customPath) return customPath; + + const profileDir = join(homedir(), '.cloakbrowser-profile'); + if (!existsSync(profileDir)) { + mkdirSync(profileDir, { recursive: true }); + } + return profileDir; +} + +/** + * Launch a CloakBrowser persistent context with the shared profile. + * + * Headless mode is resolved in order: + * 1. `options.headless` (explicit caller preference) + * 2. `CLOAKBROWSER_HEADLESS` env var + * 3. `true` (safe default) + */ +export async function launchBrowser(options: { + headless?: boolean; +}): Promise { + const profilePath = getProfilePath(); + const envHeadless = process.env.CLOAKBROWSER_HEADLESS; + const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true); + + console.log(`Using profile: ${profilePath}`); + console.log(`Headless mode: ${headless}`); + + const context = await launchPersistentContext({ + userDataDir: profilePath, + headless, + humanize: true, + }); + + return context; +} + +/** + * Return a ready `{ page, browser }` pair using the shared persistent profile. + * + * Re-uses the first existing page or opens a new one if the context is empty. + */ +export async function getPage(options?: { + headless?: boolean; +}): Promise<{ page: Page; browser: BrowserContext }> { + const browser = await launchBrowser({ headless: options?.headless }); + const page = browser.pages()[0] || (await browser.newPage()); + return { page, browser }; +} diff --git a/skills/web-automation/codex/scripts/scan-local-app.ts b/skills/web-automation/codex/scripts/scan-local-app.ts index 00e213e..cf8d190 100644 --- a/skills/web-automation/codex/scripts/scan-local-app.ts +++ b/skills/web-automation/codex/scripts/scan-local-app.ts @@ -3,7 +3,8 @@ import { mkdirSync, writeFileSync } from 'fs'; import { dirname, resolve } from 'path'; -import { getPage } from './browse.js'; +import type { Page } from 'playwright-core'; +import { getPage } from './lib/browser.js'; type NavResult = { requestedUrl: string; @@ -40,30 +41,34 @@ function getRoutes(baseUrl: string): string[] { return [baseUrl]; } -async function gotoWithStatus(page: any, url: string): Promise { +type GotoError = { error: unknown }; + +async function gotoWithStatus(page: Page, url: string): Promise { const response = await page .goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) - .catch((error: unknown) => ({ error })); + .catch((error: unknown): GotoError => ({ error })); - if (response?.error) { + if (response !== null && response !== undefined && 'error' in response) { + const gotoError = response as GotoError; return { requestedUrl: url, url: page.url(), status: null, title: await page.title().catch(() => ''), - error: String(response.error), + error: String(gotoError.error), }; } + const httpResponse = response as Awaited>; return { requestedUrl: url, url: page.url(), - status: response ? response.status() : null, + status: httpResponse ? httpResponse.status() : null, title: await page.title().catch(() => ''), }; } -async function textOrNull(page: any, selector: string): Promise { +async function textOrNull(page: Page, selector: string): Promise { const locator = page.locator(selector).first(); try { if ((await locator.count()) === 0) return null; @@ -74,7 +79,7 @@ async function textOrNull(page: any, selector: string): Promise { } } -async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { +async function loginIfConfigured(page: Page, baseUrl: string, lines: string[]) { const loginPath = env('SCAN_LOGIN_PATH'); const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); @@ -110,7 +115,7 @@ async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { lines.push(''); } -async function checkRoutes(page: any, baseUrl: string, lines: string[]) { +async function checkRoutes(page: Page, baseUrl: string, lines: string[]) { const routes = getRoutes(baseUrl); const routeChecks: RouteCheck[] = []; diff --git a/skills/web-automation/codex/scripts/tsconfig.json b/skills/web-automation/codex/scripts/tsconfig.json index 4c23583..689017a 100644 --- a/skills/web-automation/codex/scripts/tsconfig.json +++ b/skills/web-automation/codex/scripts/tsconfig.json @@ -11,6 +11,6 @@ "outDir": "./dist", "rootDir": "." }, - "include": ["*.ts"], + "include": ["*.ts", "lib/**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/skills/web-automation/cursor/.generated-manifest.json b/skills/web-automation/cursor/.generated-manifest.json index 97d560a..528c904 100644 --- a/skills/web-automation/cursor/.generated-manifest.json +++ b/skills/web-automation/cursor/.generated-manifest.json @@ -7,13 +7,13 @@ "path": "scripts/auth.ts", "kind": "file", "mode": "644", - "sha256": "ce0a8aae0bc41b86e11aab51cc0e0cfa484a1934807f147c05c9bd38d416c066" + "sha256": "c0940f452437b05b95e58a9a7ab265fb50aa412bd672e82fedd6a37cbfb3d505" }, { "path": "scripts/browse.ts", "kind": "file", "mode": "644", - "sha256": "42da9cdc6806b8d7d8d814952ad9540033b6c6a4cbe9844ada328b2ceace67c9" + "sha256": "d7e4b4c50116032e5a00f90bca27e069dfc5bbf6eeb06ec8f8edc9e5a9792ab8" }, { "path": "scripts/check-install.js", @@ -31,7 +31,13 @@ "path": "scripts/flow.ts", "kind": "file", "mode": "644", - "sha256": "b1c256bf6a206473512a4c0555c891893a48025529da282fa6cd07e68ad3d051" + "sha256": "94f3e7987cab253dc3c9e80656a11759fada13b3915608bff7ae08418602f366" + }, + { + "path": "scripts/lib/browser.ts", + "kind": "file", + "mode": "644", + "sha256": "879b5f883ff1f888d45ed20be05c2d9bc3d6fe5305a1972b7d49a7e6c0e24934" }, { "path": "scripts/package.json", @@ -49,7 +55,7 @@ "path": "scripts/scan-local-app.ts", "kind": "file", "mode": "644", - "sha256": "3f42f9bb2d355fefc8645d2b2acfa3107bd87f9c2579b2631c94132bed0abea4" + "sha256": "9e1818c254a633e087715609152936dcb3613a0aa724d40a8a13460510691dc7" }, { "path": "scripts/scrape.ts", @@ -79,7 +85,7 @@ "path": "scripts/tsconfig.json", "kind": "file", "mode": "644", - "sha256": "5f9a83c8caab167eb20defbb5afde58f2bb573a300af99654997dcb3372408e0" + "sha256": "e5f22d72266068cf410976c880511f2ec1875445256e11739a5e1de6ffedf38d" }, { "path": "scripts/turndown-plugin-gfm.d.ts", diff --git a/skills/web-automation/cursor/scripts/auth.ts b/skills/web-automation/cursor/scripts/auth.ts index 272ee6d..b06e846 100644 --- a/skills/web-automation/cursor/scripts/auth.ts +++ b/skills/web-automation/cursor/scripts/auth.ts @@ -11,7 +11,7 @@ * npx tsx auth.ts --url "https://example.com" --type auto */ -import { getPage, launchBrowser } from './browse.js'; +import { getPage, launchBrowser } from './lib/browser.js'; import parseArgs from 'minimist'; import type { Page, BrowserContext } from 'playwright-core'; import { createInterface } from 'readline'; diff --git a/skills/web-automation/cursor/scripts/browse.ts b/skills/web-automation/cursor/scripts/browse.ts index 3d27c9e..e270db8 100644 --- a/skills/web-automation/cursor/scripts/browse.ts +++ b/skills/web-automation/cursor/scripts/browse.ts @@ -10,12 +10,13 @@ * npx tsx browse.ts --url "https://example.com" --headless false --wait 5000 */ -import { launchPersistentContext } from 'cloakbrowser'; -import { homedir } from 'os'; -import { join } from 'path'; -import { existsSync, mkdirSync } from 'fs'; import parseArgs from 'minimist'; -import type { Page, BrowserContext } from 'playwright-core'; +import type { BrowserContext } from 'playwright-core'; + +import { getProfilePath, launchBrowser, getPage } from './lib/browser.js'; + +// Re-export shared helpers so existing imports of browse.ts continue to work. +export { getProfilePath, launchBrowser, getPage }; interface BrowseOptions { url: string; @@ -37,36 +38,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -const getProfilePath = (): string => { - const customPath = process.env.CLOAKBROWSER_PROFILE_PATH; - if (customPath) return customPath; - - const profileDir = join(homedir(), '.cloakbrowser-profile'); - if (!existsSync(profileDir)) { - mkdirSync(profileDir, { recursive: true }); - } - return profileDir; -}; - -export async function launchBrowser(options: { - headless?: boolean; -}): Promise { - const profilePath = getProfilePath(); - const envHeadless = process.env.CLOAKBROWSER_HEADLESS; - const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true); - - console.log(`Using profile: ${profilePath}`); - console.log(`Headless mode: ${headless}`); - - const context = await launchPersistentContext({ - userDataDir: profilePath, - headless, - humanize: true, - }); - - return context; -} - export async function browse(options: BrowseOptions): Promise { const browser = await launchBrowser({ headless: options.headless }); const page = browser.pages()[0] || await browser.newPage(); @@ -112,14 +83,6 @@ export async function browse(options: BrowseOptions): Promise { } } -export async function getPage(options?: { - headless?: boolean; -}): Promise<{ page: Page; browser: BrowserContext }> { - const browser = await launchBrowser({ headless: options?.headless }); - const page = browser.pages()[0] || await browser.newPage(); - return { page, browser }; -} - async function main() { const args = parseArgs(process.argv.slice(2), { string: ['url', 'output'], diff --git a/skills/web-automation/cursor/scripts/flow.ts b/skills/web-automation/cursor/scripts/flow.ts index dfe2d8c..6b8b5e6 100644 --- a/skills/web-automation/cursor/scripts/flow.ts +++ b/skills/web-automation/cursor/scripts/flow.ts @@ -3,7 +3,7 @@ import parseArgs from 'minimist'; import type { Page } from 'playwright-core'; -import { launchBrowser } from './browse'; +import { launchBrowser } from './lib/browser.js'; type Step = | { action: 'goto'; url: string } diff --git a/skills/web-automation/cursor/scripts/lib/browser.ts b/skills/web-automation/cursor/scripts/lib/browser.ts new file mode 100644 index 0000000..5723bad --- /dev/null +++ b/skills/web-automation/cursor/scripts/lib/browser.ts @@ -0,0 +1,76 @@ +// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`. +/** + * Shared browser-launch and profile helpers for web-automation scripts. + * + * Centralises the three reusable primitives that every command entry point + * needs: + * - getProfilePath() — resolve the persistent CloakBrowser profile dir + * - launchBrowser() — launch a CloakBrowser persistent context + * - getPage() — get a ready Page + BrowserContext pair + * + * All command entry points (auth.ts, browse.ts, flow.ts, scan-local-app.ts) + * import from here instead of duplicating these bodies. + */ + +import { launchPersistentContext } from 'cloakbrowser'; +import { existsSync, mkdirSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import type { BrowserContext, Page } from 'playwright-core'; + +/** + * Return the path to the persistent CloakBrowser profile directory. + * + * Uses `CLOAKBROWSER_PROFILE_PATH` env var when set; otherwise defaults to + * `~/.cloakbrowser-profile/` and creates it if it does not exist. + */ +export function getProfilePath(): string { + const customPath = process.env.CLOAKBROWSER_PROFILE_PATH; + if (customPath) return customPath; + + const profileDir = join(homedir(), '.cloakbrowser-profile'); + if (!existsSync(profileDir)) { + mkdirSync(profileDir, { recursive: true }); + } + return profileDir; +} + +/** + * Launch a CloakBrowser persistent context with the shared profile. + * + * Headless mode is resolved in order: + * 1. `options.headless` (explicit caller preference) + * 2. `CLOAKBROWSER_HEADLESS` env var + * 3. `true` (safe default) + */ +export async function launchBrowser(options: { + headless?: boolean; +}): Promise { + const profilePath = getProfilePath(); + const envHeadless = process.env.CLOAKBROWSER_HEADLESS; + const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true); + + console.log(`Using profile: ${profilePath}`); + console.log(`Headless mode: ${headless}`); + + const context = await launchPersistentContext({ + userDataDir: profilePath, + headless, + humanize: true, + }); + + return context; +} + +/** + * Return a ready `{ page, browser }` pair using the shared persistent profile. + * + * Re-uses the first existing page or opens a new one if the context is empty. + */ +export async function getPage(options?: { + headless?: boolean; +}): Promise<{ page: Page; browser: BrowserContext }> { + const browser = await launchBrowser({ headless: options?.headless }); + const page = browser.pages()[0] || (await browser.newPage()); + return { page, browser }; +} diff --git a/skills/web-automation/cursor/scripts/scan-local-app.ts b/skills/web-automation/cursor/scripts/scan-local-app.ts index 00e213e..cf8d190 100644 --- a/skills/web-automation/cursor/scripts/scan-local-app.ts +++ b/skills/web-automation/cursor/scripts/scan-local-app.ts @@ -3,7 +3,8 @@ import { mkdirSync, writeFileSync } from 'fs'; import { dirname, resolve } from 'path'; -import { getPage } from './browse.js'; +import type { Page } from 'playwright-core'; +import { getPage } from './lib/browser.js'; type NavResult = { requestedUrl: string; @@ -40,30 +41,34 @@ function getRoutes(baseUrl: string): string[] { return [baseUrl]; } -async function gotoWithStatus(page: any, url: string): Promise { +type GotoError = { error: unknown }; + +async function gotoWithStatus(page: Page, url: string): Promise { const response = await page .goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) - .catch((error: unknown) => ({ error })); + .catch((error: unknown): GotoError => ({ error })); - if (response?.error) { + if (response !== null && response !== undefined && 'error' in response) { + const gotoError = response as GotoError; return { requestedUrl: url, url: page.url(), status: null, title: await page.title().catch(() => ''), - error: String(response.error), + error: String(gotoError.error), }; } + const httpResponse = response as Awaited>; return { requestedUrl: url, url: page.url(), - status: response ? response.status() : null, + status: httpResponse ? httpResponse.status() : null, title: await page.title().catch(() => ''), }; } -async function textOrNull(page: any, selector: string): Promise { +async function textOrNull(page: Page, selector: string): Promise { const locator = page.locator(selector).first(); try { if ((await locator.count()) === 0) return null; @@ -74,7 +79,7 @@ async function textOrNull(page: any, selector: string): Promise { } } -async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { +async function loginIfConfigured(page: Page, baseUrl: string, lines: string[]) { const loginPath = env('SCAN_LOGIN_PATH'); const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); @@ -110,7 +115,7 @@ async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { lines.push(''); } -async function checkRoutes(page: any, baseUrl: string, lines: string[]) { +async function checkRoutes(page: Page, baseUrl: string, lines: string[]) { const routes = getRoutes(baseUrl); const routeChecks: RouteCheck[] = []; diff --git a/skills/web-automation/cursor/scripts/tsconfig.json b/skills/web-automation/cursor/scripts/tsconfig.json index 4c23583..689017a 100644 --- a/skills/web-automation/cursor/scripts/tsconfig.json +++ b/skills/web-automation/cursor/scripts/tsconfig.json @@ -11,6 +11,6 @@ "outDir": "./dist", "rootDir": "." }, - "include": ["*.ts"], + "include": ["*.ts", "lib/**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/skills/web-automation/opencode/.generated-manifest.json b/skills/web-automation/opencode/.generated-manifest.json index 765ba14..73620b8 100644 --- a/skills/web-automation/opencode/.generated-manifest.json +++ b/skills/web-automation/opencode/.generated-manifest.json @@ -7,13 +7,13 @@ "path": "scripts/auth.ts", "kind": "file", "mode": "644", - "sha256": "ce0a8aae0bc41b86e11aab51cc0e0cfa484a1934807f147c05c9bd38d416c066" + "sha256": "c0940f452437b05b95e58a9a7ab265fb50aa412bd672e82fedd6a37cbfb3d505" }, { "path": "scripts/browse.ts", "kind": "file", "mode": "644", - "sha256": "42da9cdc6806b8d7d8d814952ad9540033b6c6a4cbe9844ada328b2ceace67c9" + "sha256": "d7e4b4c50116032e5a00f90bca27e069dfc5bbf6eeb06ec8f8edc9e5a9792ab8" }, { "path": "scripts/check-install.js", @@ -31,7 +31,13 @@ "path": "scripts/flow.ts", "kind": "file", "mode": "644", - "sha256": "b1c256bf6a206473512a4c0555c891893a48025529da282fa6cd07e68ad3d051" + "sha256": "94f3e7987cab253dc3c9e80656a11759fada13b3915608bff7ae08418602f366" + }, + { + "path": "scripts/lib/browser.ts", + "kind": "file", + "mode": "644", + "sha256": "879b5f883ff1f888d45ed20be05c2d9bc3d6fe5305a1972b7d49a7e6c0e24934" }, { "path": "scripts/package.json", @@ -49,7 +55,7 @@ "path": "scripts/scan-local-app.ts", "kind": "file", "mode": "644", - "sha256": "3f42f9bb2d355fefc8645d2b2acfa3107bd87f9c2579b2631c94132bed0abea4" + "sha256": "9e1818c254a633e087715609152936dcb3613a0aa724d40a8a13460510691dc7" }, { "path": "scripts/scrape.ts", @@ -79,7 +85,7 @@ "path": "scripts/tsconfig.json", "kind": "file", "mode": "644", - "sha256": "5f9a83c8caab167eb20defbb5afde58f2bb573a300af99654997dcb3372408e0" + "sha256": "e5f22d72266068cf410976c880511f2ec1875445256e11739a5e1de6ffedf38d" }, { "path": "scripts/turndown-plugin-gfm.d.ts", diff --git a/skills/web-automation/opencode/scripts/auth.ts b/skills/web-automation/opencode/scripts/auth.ts index 272ee6d..b06e846 100644 --- a/skills/web-automation/opencode/scripts/auth.ts +++ b/skills/web-automation/opencode/scripts/auth.ts @@ -11,7 +11,7 @@ * npx tsx auth.ts --url "https://example.com" --type auto */ -import { getPage, launchBrowser } from './browse.js'; +import { getPage, launchBrowser } from './lib/browser.js'; import parseArgs from 'minimist'; import type { Page, BrowserContext } from 'playwright-core'; import { createInterface } from 'readline'; diff --git a/skills/web-automation/opencode/scripts/browse.ts b/skills/web-automation/opencode/scripts/browse.ts index 3d27c9e..e270db8 100644 --- a/skills/web-automation/opencode/scripts/browse.ts +++ b/skills/web-automation/opencode/scripts/browse.ts @@ -10,12 +10,13 @@ * npx tsx browse.ts --url "https://example.com" --headless false --wait 5000 */ -import { launchPersistentContext } from 'cloakbrowser'; -import { homedir } from 'os'; -import { join } from 'path'; -import { existsSync, mkdirSync } from 'fs'; import parseArgs from 'minimist'; -import type { Page, BrowserContext } from 'playwright-core'; +import type { BrowserContext } from 'playwright-core'; + +import { getProfilePath, launchBrowser, getPage } from './lib/browser.js'; + +// Re-export shared helpers so existing imports of browse.ts continue to work. +export { getProfilePath, launchBrowser, getPage }; interface BrowseOptions { url: string; @@ -37,36 +38,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -const getProfilePath = (): string => { - const customPath = process.env.CLOAKBROWSER_PROFILE_PATH; - if (customPath) return customPath; - - const profileDir = join(homedir(), '.cloakbrowser-profile'); - if (!existsSync(profileDir)) { - mkdirSync(profileDir, { recursive: true }); - } - return profileDir; -}; - -export async function launchBrowser(options: { - headless?: boolean; -}): Promise { - const profilePath = getProfilePath(); - const envHeadless = process.env.CLOAKBROWSER_HEADLESS; - const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true); - - console.log(`Using profile: ${profilePath}`); - console.log(`Headless mode: ${headless}`); - - const context = await launchPersistentContext({ - userDataDir: profilePath, - headless, - humanize: true, - }); - - return context; -} - export async function browse(options: BrowseOptions): Promise { const browser = await launchBrowser({ headless: options.headless }); const page = browser.pages()[0] || await browser.newPage(); @@ -112,14 +83,6 @@ export async function browse(options: BrowseOptions): Promise { } } -export async function getPage(options?: { - headless?: boolean; -}): Promise<{ page: Page; browser: BrowserContext }> { - const browser = await launchBrowser({ headless: options?.headless }); - const page = browser.pages()[0] || await browser.newPage(); - return { page, browser }; -} - async function main() { const args = parseArgs(process.argv.slice(2), { string: ['url', 'output'], diff --git a/skills/web-automation/opencode/scripts/flow.ts b/skills/web-automation/opencode/scripts/flow.ts index dfe2d8c..6b8b5e6 100644 --- a/skills/web-automation/opencode/scripts/flow.ts +++ b/skills/web-automation/opencode/scripts/flow.ts @@ -3,7 +3,7 @@ import parseArgs from 'minimist'; import type { Page } from 'playwright-core'; -import { launchBrowser } from './browse'; +import { launchBrowser } from './lib/browser.js'; type Step = | { action: 'goto'; url: string } diff --git a/skills/web-automation/opencode/scripts/lib/browser.ts b/skills/web-automation/opencode/scripts/lib/browser.ts new file mode 100644 index 0000000..5723bad --- /dev/null +++ b/skills/web-automation/opencode/scripts/lib/browser.ts @@ -0,0 +1,76 @@ +// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`. +/** + * Shared browser-launch and profile helpers for web-automation scripts. + * + * Centralises the three reusable primitives that every command entry point + * needs: + * - getProfilePath() — resolve the persistent CloakBrowser profile dir + * - launchBrowser() — launch a CloakBrowser persistent context + * - getPage() — get a ready Page + BrowserContext pair + * + * All command entry points (auth.ts, browse.ts, flow.ts, scan-local-app.ts) + * import from here instead of duplicating these bodies. + */ + +import { launchPersistentContext } from 'cloakbrowser'; +import { existsSync, mkdirSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import type { BrowserContext, Page } from 'playwright-core'; + +/** + * Return the path to the persistent CloakBrowser profile directory. + * + * Uses `CLOAKBROWSER_PROFILE_PATH` env var when set; otherwise defaults to + * `~/.cloakbrowser-profile/` and creates it if it does not exist. + */ +export function getProfilePath(): string { + const customPath = process.env.CLOAKBROWSER_PROFILE_PATH; + if (customPath) return customPath; + + const profileDir = join(homedir(), '.cloakbrowser-profile'); + if (!existsSync(profileDir)) { + mkdirSync(profileDir, { recursive: true }); + } + return profileDir; +} + +/** + * Launch a CloakBrowser persistent context with the shared profile. + * + * Headless mode is resolved in order: + * 1. `options.headless` (explicit caller preference) + * 2. `CLOAKBROWSER_HEADLESS` env var + * 3. `true` (safe default) + */ +export async function launchBrowser(options: { + headless?: boolean; +}): Promise { + const profilePath = getProfilePath(); + const envHeadless = process.env.CLOAKBROWSER_HEADLESS; + const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true); + + console.log(`Using profile: ${profilePath}`); + console.log(`Headless mode: ${headless}`); + + const context = await launchPersistentContext({ + userDataDir: profilePath, + headless, + humanize: true, + }); + + return context; +} + +/** + * Return a ready `{ page, browser }` pair using the shared persistent profile. + * + * Re-uses the first existing page or opens a new one if the context is empty. + */ +export async function getPage(options?: { + headless?: boolean; +}): Promise<{ page: Page; browser: BrowserContext }> { + const browser = await launchBrowser({ headless: options?.headless }); + const page = browser.pages()[0] || (await browser.newPage()); + return { page, browser }; +} diff --git a/skills/web-automation/opencode/scripts/scan-local-app.ts b/skills/web-automation/opencode/scripts/scan-local-app.ts index 00e213e..cf8d190 100644 --- a/skills/web-automation/opencode/scripts/scan-local-app.ts +++ b/skills/web-automation/opencode/scripts/scan-local-app.ts @@ -3,7 +3,8 @@ import { mkdirSync, writeFileSync } from 'fs'; import { dirname, resolve } from 'path'; -import { getPage } from './browse.js'; +import type { Page } from 'playwright-core'; +import { getPage } from './lib/browser.js'; type NavResult = { requestedUrl: string; @@ -40,30 +41,34 @@ function getRoutes(baseUrl: string): string[] { return [baseUrl]; } -async function gotoWithStatus(page: any, url: string): Promise { +type GotoError = { error: unknown }; + +async function gotoWithStatus(page: Page, url: string): Promise { const response = await page .goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) - .catch((error: unknown) => ({ error })); + .catch((error: unknown): GotoError => ({ error })); - if (response?.error) { + if (response !== null && response !== undefined && 'error' in response) { + const gotoError = response as GotoError; return { requestedUrl: url, url: page.url(), status: null, title: await page.title().catch(() => ''), - error: String(response.error), + error: String(gotoError.error), }; } + const httpResponse = response as Awaited>; return { requestedUrl: url, url: page.url(), - status: response ? response.status() : null, + status: httpResponse ? httpResponse.status() : null, title: await page.title().catch(() => ''), }; } -async function textOrNull(page: any, selector: string): Promise { +async function textOrNull(page: Page, selector: string): Promise { const locator = page.locator(selector).first(); try { if ((await locator.count()) === 0) return null; @@ -74,7 +79,7 @@ async function textOrNull(page: any, selector: string): Promise { } } -async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { +async function loginIfConfigured(page: Page, baseUrl: string, lines: string[]) { const loginPath = env('SCAN_LOGIN_PATH'); const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); @@ -110,7 +115,7 @@ async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { lines.push(''); } -async function checkRoutes(page: any, baseUrl: string, lines: string[]) { +async function checkRoutes(page: Page, baseUrl: string, lines: string[]) { const routes = getRoutes(baseUrl); const routeChecks: RouteCheck[] = []; diff --git a/skills/web-automation/opencode/scripts/tsconfig.json b/skills/web-automation/opencode/scripts/tsconfig.json index 4c23583..689017a 100644 --- a/skills/web-automation/opencode/scripts/tsconfig.json +++ b/skills/web-automation/opencode/scripts/tsconfig.json @@ -11,6 +11,6 @@ "outDir": "./dist", "rootDir": "." }, - "include": ["*.ts"], + "include": ["*.ts", "lib/**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/skills/web-automation/pi/.generated-manifest.json b/skills/web-automation/pi/.generated-manifest.json index 8d67b77..a7a7f0d 100644 --- a/skills/web-automation/pi/.generated-manifest.json +++ b/skills/web-automation/pi/.generated-manifest.json @@ -7,13 +7,13 @@ "path": "scripts/auth.ts", "kind": "file", "mode": "644", - "sha256": "ce0a8aae0bc41b86e11aab51cc0e0cfa484a1934807f147c05c9bd38d416c066" + "sha256": "c0940f452437b05b95e58a9a7ab265fb50aa412bd672e82fedd6a37cbfb3d505" }, { "path": "scripts/browse.ts", "kind": "file", "mode": "644", - "sha256": "42da9cdc6806b8d7d8d814952ad9540033b6c6a4cbe9844ada328b2ceace67c9" + "sha256": "d7e4b4c50116032e5a00f90bca27e069dfc5bbf6eeb06ec8f8edc9e5a9792ab8" }, { "path": "scripts/check-install.js", @@ -31,7 +31,13 @@ "path": "scripts/flow.ts", "kind": "file", "mode": "644", - "sha256": "b1c256bf6a206473512a4c0555c891893a48025529da282fa6cd07e68ad3d051" + "sha256": "94f3e7987cab253dc3c9e80656a11759fada13b3915608bff7ae08418602f366" + }, + { + "path": "scripts/lib/browser.ts", + "kind": "file", + "mode": "644", + "sha256": "879b5f883ff1f888d45ed20be05c2d9bc3d6fe5305a1972b7d49a7e6c0e24934" }, { "path": "scripts/package.json", @@ -49,7 +55,7 @@ "path": "scripts/scan-local-app.ts", "kind": "file", "mode": "644", - "sha256": "3f42f9bb2d355fefc8645d2b2acfa3107bd87f9c2579b2631c94132bed0abea4" + "sha256": "9e1818c254a633e087715609152936dcb3613a0aa724d40a8a13460510691dc7" }, { "path": "scripts/scrape.ts", @@ -79,7 +85,7 @@ "path": "scripts/tsconfig.json", "kind": "file", "mode": "644", - "sha256": "5f9a83c8caab167eb20defbb5afde58f2bb573a300af99654997dcb3372408e0" + "sha256": "e5f22d72266068cf410976c880511f2ec1875445256e11739a5e1de6ffedf38d" }, { "path": "scripts/turndown-plugin-gfm.d.ts", diff --git a/skills/web-automation/pi/scripts/auth.ts b/skills/web-automation/pi/scripts/auth.ts index 272ee6d..b06e846 100644 --- a/skills/web-automation/pi/scripts/auth.ts +++ b/skills/web-automation/pi/scripts/auth.ts @@ -11,7 +11,7 @@ * npx tsx auth.ts --url "https://example.com" --type auto */ -import { getPage, launchBrowser } from './browse.js'; +import { getPage, launchBrowser } from './lib/browser.js'; import parseArgs from 'minimist'; import type { Page, BrowserContext } from 'playwright-core'; import { createInterface } from 'readline'; diff --git a/skills/web-automation/pi/scripts/browse.ts b/skills/web-automation/pi/scripts/browse.ts index 3d27c9e..e270db8 100644 --- a/skills/web-automation/pi/scripts/browse.ts +++ b/skills/web-automation/pi/scripts/browse.ts @@ -10,12 +10,13 @@ * npx tsx browse.ts --url "https://example.com" --headless false --wait 5000 */ -import { launchPersistentContext } from 'cloakbrowser'; -import { homedir } from 'os'; -import { join } from 'path'; -import { existsSync, mkdirSync } from 'fs'; import parseArgs from 'minimist'; -import type { Page, BrowserContext } from 'playwright-core'; +import type { BrowserContext } from 'playwright-core'; + +import { getProfilePath, launchBrowser, getPage } from './lib/browser.js'; + +// Re-export shared helpers so existing imports of browse.ts continue to work. +export { getProfilePath, launchBrowser, getPage }; interface BrowseOptions { url: string; @@ -37,36 +38,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -const getProfilePath = (): string => { - const customPath = process.env.CLOAKBROWSER_PROFILE_PATH; - if (customPath) return customPath; - - const profileDir = join(homedir(), '.cloakbrowser-profile'); - if (!existsSync(profileDir)) { - mkdirSync(profileDir, { recursive: true }); - } - return profileDir; -}; - -export async function launchBrowser(options: { - headless?: boolean; -}): Promise { - const profilePath = getProfilePath(); - const envHeadless = process.env.CLOAKBROWSER_HEADLESS; - const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true); - - console.log(`Using profile: ${profilePath}`); - console.log(`Headless mode: ${headless}`); - - const context = await launchPersistentContext({ - userDataDir: profilePath, - headless, - humanize: true, - }); - - return context; -} - export async function browse(options: BrowseOptions): Promise { const browser = await launchBrowser({ headless: options.headless }); const page = browser.pages()[0] || await browser.newPage(); @@ -112,14 +83,6 @@ export async function browse(options: BrowseOptions): Promise { } } -export async function getPage(options?: { - headless?: boolean; -}): Promise<{ page: Page; browser: BrowserContext }> { - const browser = await launchBrowser({ headless: options?.headless }); - const page = browser.pages()[0] || await browser.newPage(); - return { page, browser }; -} - async function main() { const args = parseArgs(process.argv.slice(2), { string: ['url', 'output'], diff --git a/skills/web-automation/pi/scripts/flow.ts b/skills/web-automation/pi/scripts/flow.ts index dfe2d8c..6b8b5e6 100644 --- a/skills/web-automation/pi/scripts/flow.ts +++ b/skills/web-automation/pi/scripts/flow.ts @@ -3,7 +3,7 @@ import parseArgs from 'minimist'; import type { Page } from 'playwright-core'; -import { launchBrowser } from './browse'; +import { launchBrowser } from './lib/browser.js'; type Step = | { action: 'goto'; url: string } diff --git a/skills/web-automation/pi/scripts/lib/browser.ts b/skills/web-automation/pi/scripts/lib/browser.ts new file mode 100644 index 0000000..5723bad --- /dev/null +++ b/skills/web-automation/pi/scripts/lib/browser.ts @@ -0,0 +1,76 @@ +// ⚠️ GENERATED FILE – do not edit directly. Edit the canonical source in skills/web-automation/shared/ and run `pnpm run sync:pi`. +/** + * Shared browser-launch and profile helpers for web-automation scripts. + * + * Centralises the three reusable primitives that every command entry point + * needs: + * - getProfilePath() — resolve the persistent CloakBrowser profile dir + * - launchBrowser() — launch a CloakBrowser persistent context + * - getPage() — get a ready Page + BrowserContext pair + * + * All command entry points (auth.ts, browse.ts, flow.ts, scan-local-app.ts) + * import from here instead of duplicating these bodies. + */ + +import { launchPersistentContext } from 'cloakbrowser'; +import { existsSync, mkdirSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import type { BrowserContext, Page } from 'playwright-core'; + +/** + * Return the path to the persistent CloakBrowser profile directory. + * + * Uses `CLOAKBROWSER_PROFILE_PATH` env var when set; otherwise defaults to + * `~/.cloakbrowser-profile/` and creates it if it does not exist. + */ +export function getProfilePath(): string { + const customPath = process.env.CLOAKBROWSER_PROFILE_PATH; + if (customPath) return customPath; + + const profileDir = join(homedir(), '.cloakbrowser-profile'); + if (!existsSync(profileDir)) { + mkdirSync(profileDir, { recursive: true }); + } + return profileDir; +} + +/** + * Launch a CloakBrowser persistent context with the shared profile. + * + * Headless mode is resolved in order: + * 1. `options.headless` (explicit caller preference) + * 2. `CLOAKBROWSER_HEADLESS` env var + * 3. `true` (safe default) + */ +export async function launchBrowser(options: { + headless?: boolean; +}): Promise { + const profilePath = getProfilePath(); + const envHeadless = process.env.CLOAKBROWSER_HEADLESS; + const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true); + + console.log(`Using profile: ${profilePath}`); + console.log(`Headless mode: ${headless}`); + + const context = await launchPersistentContext({ + userDataDir: profilePath, + headless, + humanize: true, + }); + + return context; +} + +/** + * Return a ready `{ page, browser }` pair using the shared persistent profile. + * + * Re-uses the first existing page or opens a new one if the context is empty. + */ +export async function getPage(options?: { + headless?: boolean; +}): Promise<{ page: Page; browser: BrowserContext }> { + const browser = await launchBrowser({ headless: options?.headless }); + const page = browser.pages()[0] || (await browser.newPage()); + return { page, browser }; +} diff --git a/skills/web-automation/pi/scripts/scan-local-app.ts b/skills/web-automation/pi/scripts/scan-local-app.ts index 00e213e..cf8d190 100644 --- a/skills/web-automation/pi/scripts/scan-local-app.ts +++ b/skills/web-automation/pi/scripts/scan-local-app.ts @@ -3,7 +3,8 @@ import { mkdirSync, writeFileSync } from 'fs'; import { dirname, resolve } from 'path'; -import { getPage } from './browse.js'; +import type { Page } from 'playwright-core'; +import { getPage } from './lib/browser.js'; type NavResult = { requestedUrl: string; @@ -40,30 +41,34 @@ function getRoutes(baseUrl: string): string[] { return [baseUrl]; } -async function gotoWithStatus(page: any, url: string): Promise { +type GotoError = { error: unknown }; + +async function gotoWithStatus(page: Page, url: string): Promise { const response = await page .goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) - .catch((error: unknown) => ({ error })); + .catch((error: unknown): GotoError => ({ error })); - if (response?.error) { + if (response !== null && response !== undefined && 'error' in response) { + const gotoError = response as GotoError; return { requestedUrl: url, url: page.url(), status: null, title: await page.title().catch(() => ''), - error: String(response.error), + error: String(gotoError.error), }; } + const httpResponse = response as Awaited>; return { requestedUrl: url, url: page.url(), - status: response ? response.status() : null, + status: httpResponse ? httpResponse.status() : null, title: await page.title().catch(() => ''), }; } -async function textOrNull(page: any, selector: string): Promise { +async function textOrNull(page: Page, selector: string): Promise { const locator = page.locator(selector).first(); try { if ((await locator.count()) === 0) return null; @@ -74,7 +79,7 @@ async function textOrNull(page: any, selector: string): Promise { } } -async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { +async function loginIfConfigured(page: Page, baseUrl: string, lines: string[]) { const loginPath = env('SCAN_LOGIN_PATH'); const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); @@ -110,7 +115,7 @@ async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { lines.push(''); } -async function checkRoutes(page: any, baseUrl: string, lines: string[]) { +async function checkRoutes(page: Page, baseUrl: string, lines: string[]) { const routes = getRoutes(baseUrl); const routeChecks: RouteCheck[] = []; diff --git a/skills/web-automation/pi/scripts/tsconfig.json b/skills/web-automation/pi/scripts/tsconfig.json index 4c23583..689017a 100644 --- a/skills/web-automation/pi/scripts/tsconfig.json +++ b/skills/web-automation/pi/scripts/tsconfig.json @@ -11,6 +11,6 @@ "outDir": "./dist", "rootDir": "." }, - "include": ["*.ts"], + "include": ["*.ts", "lib/**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/skills/web-automation/shared/auth.ts b/skills/web-automation/shared/auth.ts index e79f23d..be64b94 100644 --- a/skills/web-automation/shared/auth.ts +++ b/skills/web-automation/shared/auth.ts @@ -10,7 +10,7 @@ * npx tsx auth.ts --url "https://example.com" --type auto */ -import { getPage, launchBrowser } from './browse.js'; +import { getPage, launchBrowser } from './lib/browser.js'; import parseArgs from 'minimist'; import type { Page, BrowserContext } from 'playwright-core'; import { createInterface } from 'readline'; diff --git a/skills/web-automation/shared/browse.ts b/skills/web-automation/shared/browse.ts index 01cf098..5c25c1d 100644 --- a/skills/web-automation/shared/browse.ts +++ b/skills/web-automation/shared/browse.ts @@ -9,12 +9,13 @@ * npx tsx browse.ts --url "https://example.com" --headless false --wait 5000 */ -import { launchPersistentContext } from 'cloakbrowser'; -import { homedir } from 'os'; -import { join } from 'path'; -import { existsSync, mkdirSync } from 'fs'; import parseArgs from 'minimist'; -import type { Page, BrowserContext } from 'playwright-core'; +import type { BrowserContext } from 'playwright-core'; + +import { getProfilePath, launchBrowser, getPage } from './lib/browser.js'; + +// Re-export shared helpers so existing imports of browse.ts continue to work. +export { getProfilePath, launchBrowser, getPage }; interface BrowseOptions { url: string; @@ -36,36 +37,6 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -const getProfilePath = (): string => { - const customPath = process.env.CLOAKBROWSER_PROFILE_PATH; - if (customPath) return customPath; - - const profileDir = join(homedir(), '.cloakbrowser-profile'); - if (!existsSync(profileDir)) { - mkdirSync(profileDir, { recursive: true }); - } - return profileDir; -}; - -export async function launchBrowser(options: { - headless?: boolean; -}): Promise { - const profilePath = getProfilePath(); - const envHeadless = process.env.CLOAKBROWSER_HEADLESS; - const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true); - - console.log(`Using profile: ${profilePath}`); - console.log(`Headless mode: ${headless}`); - - const context = await launchPersistentContext({ - userDataDir: profilePath, - headless, - humanize: true, - }); - - return context; -} - export async function browse(options: BrowseOptions): Promise { const browser = await launchBrowser({ headless: options.headless }); const page = browser.pages()[0] || await browser.newPage(); @@ -111,14 +82,6 @@ export async function browse(options: BrowseOptions): Promise { } } -export async function getPage(options?: { - headless?: boolean; -}): Promise<{ page: Page; browser: BrowserContext }> { - const browser = await launchBrowser({ headless: options?.headless }); - const page = browser.pages()[0] || await browser.newPage(); - return { page, browser }; -} - async function main() { const args = parseArgs(process.argv.slice(2), { string: ['url', 'output'], diff --git a/skills/web-automation/shared/flow.ts b/skills/web-automation/shared/flow.ts index 5d01e55..8161a77 100644 --- a/skills/web-automation/shared/flow.ts +++ b/skills/web-automation/shared/flow.ts @@ -2,7 +2,7 @@ import parseArgs from 'minimist'; import type { Page } from 'playwright-core'; -import { launchBrowser } from './browse'; +import { launchBrowser } from './lib/browser.js'; type Step = | { action: 'goto'; url: string } diff --git a/skills/web-automation/shared/lib/browser.ts b/skills/web-automation/shared/lib/browser.ts new file mode 100644 index 0000000..3cfd664 --- /dev/null +++ b/skills/web-automation/shared/lib/browser.ts @@ -0,0 +1,75 @@ +/** + * Shared browser-launch and profile helpers for web-automation scripts. + * + * Centralises the three reusable primitives that every command entry point + * needs: + * - getProfilePath() — resolve the persistent CloakBrowser profile dir + * - launchBrowser() — launch a CloakBrowser persistent context + * - getPage() — get a ready Page + BrowserContext pair + * + * All command entry points (auth.ts, browse.ts, flow.ts, scan-local-app.ts) + * import from here instead of duplicating these bodies. + */ + +import { launchPersistentContext } from 'cloakbrowser'; +import { existsSync, mkdirSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import type { BrowserContext, Page } from 'playwright-core'; + +/** + * Return the path to the persistent CloakBrowser profile directory. + * + * Uses `CLOAKBROWSER_PROFILE_PATH` env var when set; otherwise defaults to + * `~/.cloakbrowser-profile/` and creates it if it does not exist. + */ +export function getProfilePath(): string { + const customPath = process.env.CLOAKBROWSER_PROFILE_PATH; + if (customPath) return customPath; + + const profileDir = join(homedir(), '.cloakbrowser-profile'); + if (!existsSync(profileDir)) { + mkdirSync(profileDir, { recursive: true }); + } + return profileDir; +} + +/** + * Launch a CloakBrowser persistent context with the shared profile. + * + * Headless mode is resolved in order: + * 1. `options.headless` (explicit caller preference) + * 2. `CLOAKBROWSER_HEADLESS` env var + * 3. `true` (safe default) + */ +export async function launchBrowser(options: { + headless?: boolean; +}): Promise { + const profilePath = getProfilePath(); + const envHeadless = process.env.CLOAKBROWSER_HEADLESS; + const headless = options.headless ?? (envHeadless ? envHeadless === 'true' : true); + + console.log(`Using profile: ${profilePath}`); + console.log(`Headless mode: ${headless}`); + + const context = await launchPersistentContext({ + userDataDir: profilePath, + headless, + humanize: true, + }); + + return context; +} + +/** + * Return a ready `{ page, browser }` pair using the shared persistent profile. + * + * Re-uses the first existing page or opens a new one if the context is empty. + */ +export async function getPage(options?: { + headless?: boolean; +}): Promise<{ page: Page; browser: BrowserContext }> { + const browser = await launchBrowser({ headless: options?.headless }); + const page = browser.pages()[0] || (await browser.newPage()); + return { page, browser }; +} diff --git a/skills/web-automation/shared/scan-local-app.ts b/skills/web-automation/shared/scan-local-app.ts index 6a05b35..9865339 100644 --- a/skills/web-automation/shared/scan-local-app.ts +++ b/skills/web-automation/shared/scan-local-app.ts @@ -2,7 +2,8 @@ import { mkdirSync, writeFileSync } from 'fs'; import { dirname, resolve } from 'path'; -import { getPage } from './browse.js'; +import type { Page } from 'playwright-core'; +import { getPage } from './lib/browser.js'; type NavResult = { requestedUrl: string; @@ -39,30 +40,34 @@ function getRoutes(baseUrl: string): string[] { return [baseUrl]; } -async function gotoWithStatus(page: any, url: string): Promise { +type GotoError = { error: unknown }; + +async function gotoWithStatus(page: Page, url: string): Promise { const response = await page .goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) - .catch((error: unknown) => ({ error })); + .catch((error: unknown): GotoError => ({ error })); - if (response?.error) { + if (response !== null && response !== undefined && 'error' in response) { + const gotoError = response as GotoError; return { requestedUrl: url, url: page.url(), status: null, title: await page.title().catch(() => ''), - error: String(response.error), + error: String(gotoError.error), }; } + const httpResponse = response as Awaited>; return { requestedUrl: url, url: page.url(), - status: response ? response.status() : null, + status: httpResponse ? httpResponse.status() : null, title: await page.title().catch(() => ''), }; } -async function textOrNull(page: any, selector: string): Promise { +async function textOrNull(page: Page, selector: string): Promise { const locator = page.locator(selector).first(); try { if ((await locator.count()) === 0) return null; @@ -73,7 +78,7 @@ async function textOrNull(page: any, selector: string): Promise { } } -async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { +async function loginIfConfigured(page: Page, baseUrl: string, lines: string[]) { const loginPath = env('SCAN_LOGIN_PATH'); const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); @@ -109,7 +114,7 @@ async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) { lines.push(''); } -async function checkRoutes(page: any, baseUrl: string, lines: string[]) { +async function checkRoutes(page: Page, baseUrl: string, lines: string[]) { const routes = getRoutes(baseUrl); const routeChecks: RouteCheck[] = []; diff --git a/skills/web-automation/shared/tsconfig.json b/skills/web-automation/shared/tsconfig.json index 4c23583..689017a 100644 --- a/skills/web-automation/shared/tsconfig.json +++ b/skills/web-automation/shared/tsconfig.json @@ -11,6 +11,6 @@ "outDir": "./dist", "rootDir": "." }, - "include": ["*.ts"], + "include": ["*.ts", "lib/**/*.ts"], "exclude": ["node_modules", "dist"] }