feat(M4): Reusable code abstractions and dead-code removal

This commit is contained in:
Stefano Fiorini
2026-05-03 21:45:49 -05:00
parent 86ad783f82
commit 7495020a9c
98 changed files with 1696 additions and 950 deletions
+75
View File
@@ -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 fix (`portable_stat_perms`) replaces the only identified BSD-ism. The
Ubuntu Docker command is documented in Ubuntu Docker command is documented in
[DEVELOPMENT.md](./DEVELOPMENT.md#cross-platform-shell-support-m2). [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<T>()` 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** |
@@ -25,7 +25,13 @@
"path": "scripts/src/cli.ts", "path": "scripts/src/cli.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "5c4f4db76817fa9dbdae0fd0c75be302248d4b87fc0a53f6bd3c90407a75ae98" "sha256": "90dcc029adf0625b86c5eec44c5c1fd11bbf95ffe1185016d139c8a6982d54ff"
},
{
"path": "scripts/src/command-helpers.ts",
"kind": "file",
"mode": "644",
"sha256": "aa03d8d288c8c00485ea10d3b3a60804c1b9ee23ef265004e7912f3242dbcee7"
}, },
{ {
"path": "scripts/src/config.ts", "path": "scripts/src/config.ts",
@@ -37,7 +43,7 @@
"path": "scripts/src/confluence.ts", "path": "scripts/src/confluence.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "709d5d61fdb14e37aa4eaa7175eb7f17f0ec661376c96071020fbc9574ddbb73" "sha256": "28f65f280cd9b6119ce7eab583d0083231525ad6dc04b73389cb5dcbab5bf095"
}, },
{ {
"path": "scripts/src/files.ts", "path": "scripts/src/files.ts",
@@ -61,7 +67,7 @@
"path": "scripts/src/jira.ts", "path": "scripts/src/jira.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "485d8d618fe04eb1ce546c1694eadf15d867bc83c2a6f7df994688ab0335ea4f" "sha256": "bec0e81a0424dd412c36988cef42c01a95f044ee8346ba626e7eb8bd79379f07"
}, },
{ {
"path": "scripts/src/output.ts", "path": "scripts/src/output.ts",
@@ -73,7 +79,7 @@
"path": "scripts/src/raw.ts", "path": "scripts/src/raw.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "2309c96dd45a03509df204803de9ecf0b5ff82fd488730f55ac5dd6a23b81dd8" "sha256": "48fd54bd0cdb421badb58f9be2933a039fe3b9350bbe6191070c9f7bb0054670"
}, },
{ {
"path": "scripts/src/types.ts", "path": "scripts/src/types.ts",
@@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url";
import { Command } from "commander"; import { Command } from "commander";
import { resolveFormat } from "./command-helpers.js";
import { createConfluenceClient } from "./confluence.js"; import { createConfluenceClient } from "./confluence.js";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { readWorkspaceFile } from "./files.js"; import { readWorkspaceFile } from "./files.js";
@@ -11,7 +12,7 @@ import { runHealthCheck } from "./health.js";
import { createJiraClient } from "./jira.js"; import { createJiraClient } from "./jira.js";
import { writeOutput } from "./output.js"; import { writeOutput } from "./output.js";
import { runRawCommand } from "./raw.js"; import { runRawCommand } from "./raw.js";
import type { FetchLike, OutputFormat, Writer } from "./types.js"; import type { FetchLike, Writer } from "./types.js";
type CliContext = { type CliContext = {
cwd?: string; cwd?: string;
@@ -21,10 +22,6 @@ type CliContext = {
stderr?: Writer; stderr?: Writer;
}; };
function resolveFormat(format: string | undefined): OutputFormat {
return format === "text" ? "text" : "json";
}
function createRuntime(context: CliContext) { function createRuntime(context: CliContext) {
const cwd = context.cwd ?? process.cwd(); const cwd = context.cwd ?? process.cwd();
const env = context.env ?? process.env; const env = context.env ?? process.env;
@@ -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<T>(data: T): CommandOutput<T> {
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";
}
@@ -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`. // ⚠️ 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 { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
@@ -178,13 +179,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -224,13 +219,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -267,13 +256,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -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`. // ⚠️ 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 { markdownToAdf } from "./adf.js";
import { dryRunResponse } from "./command-helpers.js";
import { sendJsonRequest } from "./http.js"; import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js";
@@ -162,13 +163,7 @@ export function createJiraClient(options: JiraClientOptions) {
}, },
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", "/rest/api/3/issue", request.body); const raw = await send("POST", "/rest/api/3/issue", request.body);
return { ok: true, data: raw }; return { ok: true, data: raw };
@@ -193,13 +188,7 @@ export function createJiraClient(options: JiraClientOptions) {
fields, fields,
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body);
return { return {
@@ -216,13 +205,7 @@ export function createJiraClient(options: JiraClientOptions) {
body: markdownToAdf(input.body), body: markdownToAdf(input.body),
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body);
return { return {
@@ -243,13 +226,7 @@ export function createJiraClient(options: JiraClientOptions) {
}, },
); );
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body);
return { return {
@@ -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`. // ⚠️ 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 { readWorkspaceFile } from "./files.js";
import { sendJsonRequest } from "./http.js"; import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
@@ -62,13 +63,7 @@ export async function runRawCommand(
...(body === undefined ? {} : { body }), ...(body === undefined ? {} : { body }),
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const data = await sendJsonRequest({ const data = await sendJsonRequest({
config, config,
@@ -7,13 +7,13 @@
"path": "scripts/auth.ts", "path": "scripts/auth.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "ce0a8aae0bc41b86e11aab51cc0e0cfa484a1934807f147c05c9bd38d416c066" "sha256": "c0940f452437b05b95e58a9a7ab265fb50aa412bd672e82fedd6a37cbfb3d505"
}, },
{ {
"path": "scripts/browse.ts", "path": "scripts/browse.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "42da9cdc6806b8d7d8d814952ad9540033b6c6a4cbe9844ada328b2ceace67c9" "sha256": "d7e4b4c50116032e5a00f90bca27e069dfc5bbf6eeb06ec8f8edc9e5a9792ab8"
}, },
{ {
"path": "scripts/check-install.js", "path": "scripts/check-install.js",
@@ -31,7 +31,13 @@
"path": "scripts/flow.ts", "path": "scripts/flow.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "b1c256bf6a206473512a4c0555c891893a48025529da282fa6cd07e68ad3d051" "sha256": "94f3e7987cab253dc3c9e80656a11759fada13b3915608bff7ae08418602f366"
},
{
"path": "scripts/lib/browser.ts",
"kind": "file",
"mode": "644",
"sha256": "879b5f883ff1f888d45ed20be05c2d9bc3d6fe5305a1972b7d49a7e6c0e24934"
}, },
{ {
"path": "scripts/package.json", "path": "scripts/package.json",
@@ -49,7 +55,7 @@
"path": "scripts/scan-local-app.ts", "path": "scripts/scan-local-app.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "3f42f9bb2d355fefc8645d2b2acfa3107bd87f9c2579b2631c94132bed0abea4" "sha256": "9e1818c254a633e087715609152936dcb3613a0aa724d40a8a13460510691dc7"
}, },
{ {
"path": "scripts/scrape.ts", "path": "scripts/scrape.ts",
@@ -79,7 +85,7 @@
"path": "scripts/tsconfig.json", "path": "scripts/tsconfig.json",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "5f9a83c8caab167eb20defbb5afde58f2bb573a300af99654997dcb3372408e0" "sha256": "e5f22d72266068cf410976c880511f2ec1875445256e11739a5e1de6ffedf38d"
}, },
{ {
"path": "scripts/turndown-plugin-gfm.d.ts", "path": "scripts/turndown-plugin-gfm.d.ts",
@@ -11,7 +11,7 @@
* npx tsx auth.ts --url "https://example.com" --type auto * 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 parseArgs from 'minimist';
import type { Page, BrowserContext } from 'playwright-core'; import type { Page, BrowserContext } from 'playwright-core';
import { createInterface } from 'readline'; import { createInterface } from 'readline';
@@ -10,12 +10,13 @@
* npx tsx browse.ts --url "https://example.com" --headless false --wait 5000 * 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 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 { interface BrowseOptions {
url: string; url: string;
@@ -37,36 +38,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); 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<BrowserContext> {
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<BrowseResult> { export async function browse(options: BrowseOptions): Promise<BrowseResult> {
const browser = await launchBrowser({ headless: options.headless }); const browser = await launchBrowser({ headless: options.headless });
const page = browser.pages()[0] || await browser.newPage(); const page = browser.pages()[0] || await browser.newPage();
@@ -112,14 +83,6 @@ export async function browse(options: BrowseOptions): Promise<BrowseResult> {
} }
} }
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() { async function main() {
const args = parseArgs(process.argv.slice(2), { const args = parseArgs(process.argv.slice(2), {
string: ['url', 'output'], string: ['url', 'output'],
@@ -3,7 +3,7 @@
import parseArgs from 'minimist'; import parseArgs from 'minimist';
import type { Page } from 'playwright-core'; import type { Page } from 'playwright-core';
import { launchBrowser } from './browse'; import { launchBrowser } from './lib/browser.js';
type Step = type Step =
| { action: 'goto'; url: string } | { action: 'goto'; url: string }
@@ -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<BrowserContext> {
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 };
}
@@ -3,7 +3,8 @@
import { mkdirSync, writeFileSync } from 'fs'; import { mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';
import { getPage } from './browse.js'; import type { Page } from 'playwright-core';
import { getPage } from './lib/browser.js';
type NavResult = { type NavResult = {
requestedUrl: string; requestedUrl: string;
@@ -40,30 +41,34 @@ function getRoutes(baseUrl: string): string[] {
return [baseUrl]; return [baseUrl];
} }
async function gotoWithStatus(page: any, url: string): Promise<NavResult> { type GotoError = { error: unknown };
async function gotoWithStatus(page: Page, url: string): Promise<NavResult> {
const response = await page const response = await page
.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) .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 { return {
requestedUrl: url, requestedUrl: url,
url: page.url(), url: page.url(),
status: null, status: null,
title: await page.title().catch(() => ''), title: await page.title().catch(() => ''),
error: String(response.error), error: String(gotoError.error),
}; };
} }
const httpResponse = response as Awaited<ReturnType<Page['goto']>>;
return { return {
requestedUrl: url, requestedUrl: url,
url: page.url(), url: page.url(),
status: response ? response.status() : null, status: httpResponse ? httpResponse.status() : null,
title: await page.title().catch(() => ''), title: await page.title().catch(() => ''),
}; };
} }
async function textOrNull(page: any, selector: string): Promise<string | null> { async function textOrNull(page: Page, selector: string): Promise<string | null> {
const locator = page.locator(selector).first(); const locator = page.locator(selector).first();
try { try {
if ((await locator.count()) === 0) return null; if ((await locator.count()) === 0) return null;
@@ -74,7 +79,7 @@ async function textOrNull(page: any, selector: string): Promise<string | null> {
} }
} }
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 loginPath = env('SCAN_LOGIN_PATH');
const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME');
const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD');
@@ -110,7 +115,7 @@ async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) {
lines.push(''); 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 routes = getRoutes(baseUrl);
const routeChecks: RouteCheck[] = []; const routeChecks: RouteCheck[] = [];
@@ -11,6 +11,6 @@
"outDir": "./dist", "outDir": "./dist",
"rootDir": "." "rootDir": "."
}, },
"include": ["*.ts"], "include": ["*.ts", "lib/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }
+18 -2
View File
@@ -487,8 +487,12 @@ async function generateReviewerRuntimePi(repoRoot, writeRoot) {
/** /**
* Clear generated content in a root, preserving: * 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) * - .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) { async function clearGeneratedRoot(rootDir) {
let entries; let entries;
@@ -501,7 +505,18 @@ async function clearGeneratedRoot(rootDir) {
for (const entry of entries) { for (const entry of entries) {
if (entry.name === "node_modules") continue; if (entry.name === "node_modules") continue;
if (entry.name === MANIFEST_FILENAME) 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", "check-install.js",
"extract.js", "extract.js",
"flow.ts", "flow.ts",
"lib",
"scan-local-app.ts", "scan-local-app.ts",
"scrape.ts", "scrape.ts",
"test-full.ts", "test-full.ts",
+77
View File
@@ -0,0 +1,77 @@
/**
* safe-replace-dir.mjs — safely replace a directory within a safety-root boundary
*
* Exports:
* safeReplaceDir(source, target, safetyRoot) → Promise<void>
*
* 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<void>}
*/
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 });
}
+102
View File
@@ -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 <source> <target> <safety_root>
#
# Safety contract (mirrors safe-replace-dir.mjs):
# - <target> must be a non-empty path.
# - <target> must be a strict descendant of <safety_root> (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 <source> <target> <safety_root>" >&2
exit 1
fi
safe_replace_dir "$1" "$2" "$3" || exit 1
fi
+22 -20
View File
@@ -532,6 +532,24 @@ export async function buildOperationPlan({ selections, repoRoot = process.cwd(),
return { operations, prompts, reportRows, assumeYes }; 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<object>} 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() } = {}) { export async function validateRemoveTarget(target, skillsRoot, { repoRoot = process.cwd() } = {}) {
const resolvedRoot = path.resolve(skillsRoot); const resolvedRoot = path.resolve(skillsRoot);
const resolvedTarget = path.resolve(target); 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 === "package-skill") return { ...op, status: "included" };
if (op.kind === "sync-pi-package") { if (op.kind === "sync-pi-package") {
// Use the canonical generator (pnpm run sync:pi / node scripts/generate-skills.mjs). // 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 }); runCommand(process.execPath, [path.join(op.repoRoot, "scripts", "generate-skills.mjs")], { cwd: op.repoRoot });
return { ...op, status: "ok" }; return { ...op, status: "ok" };
} }
@@ -610,33 +626,19 @@ export async function executeOperation(op) {
return { ...op, status: "ok" }; return { ...op, status: "ok" };
} }
if (op.kind === "skill") { if (op.kind === "skill") {
if (op.action === "remove") { if (op.action === "remove") return 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" };
}
await copyDirectoryReplacing(op.source, op.target); await copyDirectoryReplacing(op.source, op.target);
return { ...op, status: "ok" }; return { ...op, status: "ok" };
} }
if (op.kind === "helper") { if (op.kind === "helper") {
if (op.action === "remove") { if (op.action === "remove") return 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" };
}
await installHelperAllowlist(op); await installHelperAllowlist(op);
return { ...op, status: "ok" }; return { ...op, status: "ok" };
} }
if (op.kind === "superpowers") { if (op.kind === "superpowers") {
if (op.action === "remove") return removeTarget(op);
await mkdir(path.dirname(op.target), { recursive: true }); await mkdir(path.dirname(op.target), { recursive: true });
if (op.action === "remove") { if (op.mode === "copy") {
await validateRemoveTarget(op.target, op.skillsRoot, { repoRoot: REPO_ROOT });
await rm(op.target, { recursive: true, force: true });
} else if (op.mode === "copy") {
await copyDirectoryReplacing(op.source, op.target); await copyDirectoryReplacing(op.source, op.target);
} else { } else {
await rm(op.target, { recursive: true, force: true }); await rm(op.target, { recursive: true, force: true });
-62
View File
@@ -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}."
+139
View File
@@ -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/,
);
});
@@ -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 });
}
});
@@ -25,7 +25,13 @@
"path": "scripts/src/cli.ts", "path": "scripts/src/cli.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "5c4f4db76817fa9dbdae0fd0c75be302248d4b87fc0a53f6bd3c90407a75ae98" "sha256": "90dcc029adf0625b86c5eec44c5c1fd11bbf95ffe1185016d139c8a6982d54ff"
},
{
"path": "scripts/src/command-helpers.ts",
"kind": "file",
"mode": "644",
"sha256": "aa03d8d288c8c00485ea10d3b3a60804c1b9ee23ef265004e7912f3242dbcee7"
}, },
{ {
"path": "scripts/src/config.ts", "path": "scripts/src/config.ts",
@@ -37,7 +43,7 @@
"path": "scripts/src/confluence.ts", "path": "scripts/src/confluence.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "709d5d61fdb14e37aa4eaa7175eb7f17f0ec661376c96071020fbc9574ddbb73" "sha256": "28f65f280cd9b6119ce7eab583d0083231525ad6dc04b73389cb5dcbab5bf095"
}, },
{ {
"path": "scripts/src/files.ts", "path": "scripts/src/files.ts",
@@ -61,7 +67,7 @@
"path": "scripts/src/jira.ts", "path": "scripts/src/jira.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "485d8d618fe04eb1ce546c1694eadf15d867bc83c2a6f7df994688ab0335ea4f" "sha256": "bec0e81a0424dd412c36988cef42c01a95f044ee8346ba626e7eb8bd79379f07"
}, },
{ {
"path": "scripts/src/output.ts", "path": "scripts/src/output.ts",
@@ -73,7 +79,7 @@
"path": "scripts/src/raw.ts", "path": "scripts/src/raw.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "2309c96dd45a03509df204803de9ecf0b5ff82fd488730f55ac5dd6a23b81dd8" "sha256": "48fd54bd0cdb421badb58f9be2933a039fe3b9350bbe6191070c9f7bb0054670"
}, },
{ {
"path": "scripts/src/types.ts", "path": "scripts/src/types.ts",
@@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url";
import { Command } from "commander"; import { Command } from "commander";
import { resolveFormat } from "./command-helpers.js";
import { createConfluenceClient } from "./confluence.js"; import { createConfluenceClient } from "./confluence.js";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { readWorkspaceFile } from "./files.js"; import { readWorkspaceFile } from "./files.js";
@@ -11,7 +12,7 @@ import { runHealthCheck } from "./health.js";
import { createJiraClient } from "./jira.js"; import { createJiraClient } from "./jira.js";
import { writeOutput } from "./output.js"; import { writeOutput } from "./output.js";
import { runRawCommand } from "./raw.js"; import { runRawCommand } from "./raw.js";
import type { FetchLike, OutputFormat, Writer } from "./types.js"; import type { FetchLike, Writer } from "./types.js";
type CliContext = { type CliContext = {
cwd?: string; cwd?: string;
@@ -21,10 +22,6 @@ type CliContext = {
stderr?: Writer; stderr?: Writer;
}; };
function resolveFormat(format: string | undefined): OutputFormat {
return format === "text" ? "text" : "json";
}
function createRuntime(context: CliContext) { function createRuntime(context: CliContext) {
const cwd = context.cwd ?? process.cwd(); const cwd = context.cwd ?? process.cwd();
const env = context.env ?? process.env; const env = context.env ?? process.env;
@@ -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<T>(data: T): CommandOutput<T> {
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";
}
@@ -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`. // ⚠️ 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 { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
@@ -178,13 +179,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -224,13 +219,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -267,13 +256,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -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`. // ⚠️ 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 { markdownToAdf } from "./adf.js";
import { dryRunResponse } from "./command-helpers.js";
import { sendJsonRequest } from "./http.js"; import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js";
@@ -162,13 +163,7 @@ export function createJiraClient(options: JiraClientOptions) {
}, },
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", "/rest/api/3/issue", request.body); const raw = await send("POST", "/rest/api/3/issue", request.body);
return { ok: true, data: raw }; return { ok: true, data: raw };
@@ -193,13 +188,7 @@ export function createJiraClient(options: JiraClientOptions) {
fields, fields,
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body);
return { return {
@@ -216,13 +205,7 @@ export function createJiraClient(options: JiraClientOptions) {
body: markdownToAdf(input.body), body: markdownToAdf(input.body),
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body);
return { return {
@@ -243,13 +226,7 @@ export function createJiraClient(options: JiraClientOptions) {
}, },
); );
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body);
return { return {
@@ -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`. // ⚠️ 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 { readWorkspaceFile } from "./files.js";
import { sendJsonRequest } from "./http.js"; import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
@@ -62,13 +63,7 @@ export async function runRawCommand(
...(body === undefined ? {} : { body }), ...(body === undefined ? {} : { body }),
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const data = await sendJsonRequest({ const data = await sendJsonRequest({
config, config,
@@ -25,7 +25,13 @@
"path": "scripts/src/cli.ts", "path": "scripts/src/cli.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "5c4f4db76817fa9dbdae0fd0c75be302248d4b87fc0a53f6bd3c90407a75ae98" "sha256": "90dcc029adf0625b86c5eec44c5c1fd11bbf95ffe1185016d139c8a6982d54ff"
},
{
"path": "scripts/src/command-helpers.ts",
"kind": "file",
"mode": "644",
"sha256": "aa03d8d288c8c00485ea10d3b3a60804c1b9ee23ef265004e7912f3242dbcee7"
}, },
{ {
"path": "scripts/src/config.ts", "path": "scripts/src/config.ts",
@@ -37,7 +43,7 @@
"path": "scripts/src/confluence.ts", "path": "scripts/src/confluence.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "709d5d61fdb14e37aa4eaa7175eb7f17f0ec661376c96071020fbc9574ddbb73" "sha256": "28f65f280cd9b6119ce7eab583d0083231525ad6dc04b73389cb5dcbab5bf095"
}, },
{ {
"path": "scripts/src/files.ts", "path": "scripts/src/files.ts",
@@ -61,7 +67,7 @@
"path": "scripts/src/jira.ts", "path": "scripts/src/jira.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "485d8d618fe04eb1ce546c1694eadf15d867bc83c2a6f7df994688ab0335ea4f" "sha256": "bec0e81a0424dd412c36988cef42c01a95f044ee8346ba626e7eb8bd79379f07"
}, },
{ {
"path": "scripts/src/output.ts", "path": "scripts/src/output.ts",
@@ -73,7 +79,7 @@
"path": "scripts/src/raw.ts", "path": "scripts/src/raw.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "2309c96dd45a03509df204803de9ecf0b5ff82fd488730f55ac5dd6a23b81dd8" "sha256": "48fd54bd0cdb421badb58f9be2933a039fe3b9350bbe6191070c9f7bb0054670"
}, },
{ {
"path": "scripts/src/types.ts", "path": "scripts/src/types.ts",
+2 -5
View File
@@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url";
import { Command } from "commander"; import { Command } from "commander";
import { resolveFormat } from "./command-helpers.js";
import { createConfluenceClient } from "./confluence.js"; import { createConfluenceClient } from "./confluence.js";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { readWorkspaceFile } from "./files.js"; import { readWorkspaceFile } from "./files.js";
@@ -11,7 +12,7 @@ import { runHealthCheck } from "./health.js";
import { createJiraClient } from "./jira.js"; import { createJiraClient } from "./jira.js";
import { writeOutput } from "./output.js"; import { writeOutput } from "./output.js";
import { runRawCommand } from "./raw.js"; import { runRawCommand } from "./raw.js";
import type { FetchLike, OutputFormat, Writer } from "./types.js"; import type { FetchLike, Writer } from "./types.js";
type CliContext = { type CliContext = {
cwd?: string; cwd?: string;
@@ -21,10 +22,6 @@ type CliContext = {
stderr?: Writer; stderr?: Writer;
}; };
function resolveFormat(format: string | undefined): OutputFormat {
return format === "text" ? "text" : "json";
}
function createRuntime(context: CliContext) { function createRuntime(context: CliContext) {
const cwd = context.cwd ?? process.cwd(); const cwd = context.cwd ?? process.cwd();
const env = context.env ?? process.env; const env = context.env ?? process.env;
@@ -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<T>(data: T): CommandOutput<T> {
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";
}
@@ -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`. // ⚠️ 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 { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
@@ -178,13 +179,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -224,13 +219,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -267,13 +256,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
+5 -28
View File
@@ -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`. // ⚠️ 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 { markdownToAdf } from "./adf.js";
import { dryRunResponse } from "./command-helpers.js";
import { sendJsonRequest } from "./http.js"; import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js";
@@ -162,13 +163,7 @@ export function createJiraClient(options: JiraClientOptions) {
}, },
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", "/rest/api/3/issue", request.body); const raw = await send("POST", "/rest/api/3/issue", request.body);
return { ok: true, data: raw }; return { ok: true, data: raw };
@@ -193,13 +188,7 @@ export function createJiraClient(options: JiraClientOptions) {
fields, fields,
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body);
return { return {
@@ -216,13 +205,7 @@ export function createJiraClient(options: JiraClientOptions) {
body: markdownToAdf(input.body), body: markdownToAdf(input.body),
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body);
return { return {
@@ -243,13 +226,7 @@ export function createJiraClient(options: JiraClientOptions) {
}, },
); );
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body);
return { return {
+2 -7
View File
@@ -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`. // ⚠️ 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 { readWorkspaceFile } from "./files.js";
import { sendJsonRequest } from "./http.js"; import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
@@ -62,13 +63,7 @@ export async function runRawCommand(
...(body === undefined ? {} : { body }), ...(body === undefined ? {} : { body }),
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const data = await sendJsonRequest({ const data = await sendJsonRequest({
config, config,
@@ -25,7 +25,13 @@
"path": "scripts/src/cli.ts", "path": "scripts/src/cli.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "5c4f4db76817fa9dbdae0fd0c75be302248d4b87fc0a53f6bd3c90407a75ae98" "sha256": "90dcc029adf0625b86c5eec44c5c1fd11bbf95ffe1185016d139c8a6982d54ff"
},
{
"path": "scripts/src/command-helpers.ts",
"kind": "file",
"mode": "644",
"sha256": "aa03d8d288c8c00485ea10d3b3a60804c1b9ee23ef265004e7912f3242dbcee7"
}, },
{ {
"path": "scripts/src/config.ts", "path": "scripts/src/config.ts",
@@ -37,7 +43,7 @@
"path": "scripts/src/confluence.ts", "path": "scripts/src/confluence.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "709d5d61fdb14e37aa4eaa7175eb7f17f0ec661376c96071020fbc9574ddbb73" "sha256": "28f65f280cd9b6119ce7eab583d0083231525ad6dc04b73389cb5dcbab5bf095"
}, },
{ {
"path": "scripts/src/files.ts", "path": "scripts/src/files.ts",
@@ -61,7 +67,7 @@
"path": "scripts/src/jira.ts", "path": "scripts/src/jira.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "485d8d618fe04eb1ce546c1694eadf15d867bc83c2a6f7df994688ab0335ea4f" "sha256": "bec0e81a0424dd412c36988cef42c01a95f044ee8346ba626e7eb8bd79379f07"
}, },
{ {
"path": "scripts/src/output.ts", "path": "scripts/src/output.ts",
@@ -73,7 +79,7 @@
"path": "scripts/src/raw.ts", "path": "scripts/src/raw.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "2309c96dd45a03509df204803de9ecf0b5ff82fd488730f55ac5dd6a23b81dd8" "sha256": "48fd54bd0cdb421badb58f9be2933a039fe3b9350bbe6191070c9f7bb0054670"
}, },
{ {
"path": "scripts/src/types.ts", "path": "scripts/src/types.ts",
+2 -5
View File
@@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url";
import { Command } from "commander"; import { Command } from "commander";
import { resolveFormat } from "./command-helpers.js";
import { createConfluenceClient } from "./confluence.js"; import { createConfluenceClient } from "./confluence.js";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { readWorkspaceFile } from "./files.js"; import { readWorkspaceFile } from "./files.js";
@@ -11,7 +12,7 @@ import { runHealthCheck } from "./health.js";
import { createJiraClient } from "./jira.js"; import { createJiraClient } from "./jira.js";
import { writeOutput } from "./output.js"; import { writeOutput } from "./output.js";
import { runRawCommand } from "./raw.js"; import { runRawCommand } from "./raw.js";
import type { FetchLike, OutputFormat, Writer } from "./types.js"; import type { FetchLike, Writer } from "./types.js";
type CliContext = { type CliContext = {
cwd?: string; cwd?: string;
@@ -21,10 +22,6 @@ type CliContext = {
stderr?: Writer; stderr?: Writer;
}; };
function resolveFormat(format: string | undefined): OutputFormat {
return format === "text" ? "text" : "json";
}
function createRuntime(context: CliContext) { function createRuntime(context: CliContext) {
const cwd = context.cwd ?? process.cwd(); const cwd = context.cwd ?? process.cwd();
const env = context.env ?? process.env; const env = context.env ?? process.env;
@@ -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<T>(data: T): CommandOutput<T> {
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";
}
@@ -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`. // ⚠️ 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 { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
@@ -178,13 +179,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -224,13 +219,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -267,13 +256,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
+5 -28
View File
@@ -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`. // ⚠️ 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 { markdownToAdf } from "./adf.js";
import { dryRunResponse } from "./command-helpers.js";
import { sendJsonRequest } from "./http.js"; import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js";
@@ -162,13 +163,7 @@ export function createJiraClient(options: JiraClientOptions) {
}, },
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", "/rest/api/3/issue", request.body); const raw = await send("POST", "/rest/api/3/issue", request.body);
return { ok: true, data: raw }; return { ok: true, data: raw };
@@ -193,13 +188,7 @@ export function createJiraClient(options: JiraClientOptions) {
fields, fields,
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body);
return { return {
@@ -216,13 +205,7 @@ export function createJiraClient(options: JiraClientOptions) {
body: markdownToAdf(input.body), body: markdownToAdf(input.body),
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body);
return { return {
@@ -243,13 +226,7 @@ export function createJiraClient(options: JiraClientOptions) {
}, },
); );
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body);
return { return {
+2 -7
View File
@@ -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`. // ⚠️ 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 { readWorkspaceFile } from "./files.js";
import { sendJsonRequest } from "./http.js"; import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
@@ -62,13 +63,7 @@ export async function runRawCommand(
...(body === undefined ? {} : { body }), ...(body === undefined ? {} : { body }),
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const data = await sendJsonRequest({ const data = await sendJsonRequest({
config, config,
@@ -25,7 +25,13 @@
"path": "scripts/src/cli.ts", "path": "scripts/src/cli.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "5c4f4db76817fa9dbdae0fd0c75be302248d4b87fc0a53f6bd3c90407a75ae98" "sha256": "90dcc029adf0625b86c5eec44c5c1fd11bbf95ffe1185016d139c8a6982d54ff"
},
{
"path": "scripts/src/command-helpers.ts",
"kind": "file",
"mode": "644",
"sha256": "aa03d8d288c8c00485ea10d3b3a60804c1b9ee23ef265004e7912f3242dbcee7"
}, },
{ {
"path": "scripts/src/config.ts", "path": "scripts/src/config.ts",
@@ -37,7 +43,7 @@
"path": "scripts/src/confluence.ts", "path": "scripts/src/confluence.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "709d5d61fdb14e37aa4eaa7175eb7f17f0ec661376c96071020fbc9574ddbb73" "sha256": "28f65f280cd9b6119ce7eab583d0083231525ad6dc04b73389cb5dcbab5bf095"
}, },
{ {
"path": "scripts/src/files.ts", "path": "scripts/src/files.ts",
@@ -61,7 +67,7 @@
"path": "scripts/src/jira.ts", "path": "scripts/src/jira.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "485d8d618fe04eb1ce546c1694eadf15d867bc83c2a6f7df994688ab0335ea4f" "sha256": "bec0e81a0424dd412c36988cef42c01a95f044ee8346ba626e7eb8bd79379f07"
}, },
{ {
"path": "scripts/src/output.ts", "path": "scripts/src/output.ts",
@@ -73,7 +79,7 @@
"path": "scripts/src/raw.ts", "path": "scripts/src/raw.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "2309c96dd45a03509df204803de9ecf0b5ff82fd488730f55ac5dd6a23b81dd8" "sha256": "48fd54bd0cdb421badb58f9be2933a039fe3b9350bbe6191070c9f7bb0054670"
}, },
{ {
"path": "scripts/src/types.ts", "path": "scripts/src/types.ts",
+2 -5
View File
@@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url";
import { Command } from "commander"; import { Command } from "commander";
import { resolveFormat } from "./command-helpers.js";
import { createConfluenceClient } from "./confluence.js"; import { createConfluenceClient } from "./confluence.js";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { readWorkspaceFile } from "./files.js"; import { readWorkspaceFile } from "./files.js";
@@ -11,7 +12,7 @@ import { runHealthCheck } from "./health.js";
import { createJiraClient } from "./jira.js"; import { createJiraClient } from "./jira.js";
import { writeOutput } from "./output.js"; import { writeOutput } from "./output.js";
import { runRawCommand } from "./raw.js"; import { runRawCommand } from "./raw.js";
import type { FetchLike, OutputFormat, Writer } from "./types.js"; import type { FetchLike, Writer } from "./types.js";
type CliContext = { type CliContext = {
cwd?: string; cwd?: string;
@@ -21,10 +22,6 @@ type CliContext = {
stderr?: Writer; stderr?: Writer;
}; };
function resolveFormat(format: string | undefined): OutputFormat {
return format === "text" ? "text" : "json";
}
function createRuntime(context: CliContext) { function createRuntime(context: CliContext) {
const cwd = context.cwd ?? process.cwd(); const cwd = context.cwd ?? process.cwd();
const env = context.env ?? process.env; const env = context.env ?? process.env;
@@ -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<T>(data: T): CommandOutput<T> {
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";
}
@@ -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`. // ⚠️ 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 { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
@@ -178,13 +179,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -224,13 +219,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -267,13 +256,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
+5 -28
View File
@@ -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`. // ⚠️ 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 { markdownToAdf } from "./adf.js";
import { dryRunResponse } from "./command-helpers.js";
import { sendJsonRequest } from "./http.js"; import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js";
@@ -162,13 +163,7 @@ export function createJiraClient(options: JiraClientOptions) {
}, },
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", "/rest/api/3/issue", request.body); const raw = await send("POST", "/rest/api/3/issue", request.body);
return { ok: true, data: raw }; return { ok: true, data: raw };
@@ -193,13 +188,7 @@ export function createJiraClient(options: JiraClientOptions) {
fields, fields,
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body);
return { return {
@@ -216,13 +205,7 @@ export function createJiraClient(options: JiraClientOptions) {
body: markdownToAdf(input.body), body: markdownToAdf(input.body),
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body);
return { return {
@@ -243,13 +226,7 @@ export function createJiraClient(options: JiraClientOptions) {
}, },
); );
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body);
return { return {
+2 -7
View File
@@ -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`. // ⚠️ 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 { readWorkspaceFile } from "./files.js";
import { sendJsonRequest } from "./http.js"; import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
@@ -62,13 +63,7 @@ export async function runRawCommand(
...(body === undefined ? {} : { body }), ...(body === undefined ? {} : { body }),
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const data = await sendJsonRequest({ const data = await sendJsonRequest({
config, config,
+10 -4
View File
@@ -25,7 +25,13 @@
"path": "scripts/src/cli.ts", "path": "scripts/src/cli.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "5c4f4db76817fa9dbdae0fd0c75be302248d4b87fc0a53f6bd3c90407a75ae98" "sha256": "90dcc029adf0625b86c5eec44c5c1fd11bbf95ffe1185016d139c8a6982d54ff"
},
{
"path": "scripts/src/command-helpers.ts",
"kind": "file",
"mode": "644",
"sha256": "aa03d8d288c8c00485ea10d3b3a60804c1b9ee23ef265004e7912f3242dbcee7"
}, },
{ {
"path": "scripts/src/config.ts", "path": "scripts/src/config.ts",
@@ -37,7 +43,7 @@
"path": "scripts/src/confluence.ts", "path": "scripts/src/confluence.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "709d5d61fdb14e37aa4eaa7175eb7f17f0ec661376c96071020fbc9574ddbb73" "sha256": "28f65f280cd9b6119ce7eab583d0083231525ad6dc04b73389cb5dcbab5bf095"
}, },
{ {
"path": "scripts/src/files.ts", "path": "scripts/src/files.ts",
@@ -61,7 +67,7 @@
"path": "scripts/src/jira.ts", "path": "scripts/src/jira.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "485d8d618fe04eb1ce546c1694eadf15d867bc83c2a6f7df994688ab0335ea4f" "sha256": "bec0e81a0424dd412c36988cef42c01a95f044ee8346ba626e7eb8bd79379f07"
}, },
{ {
"path": "scripts/src/output.ts", "path": "scripts/src/output.ts",
@@ -73,7 +79,7 @@
"path": "scripts/src/raw.ts", "path": "scripts/src/raw.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "2309c96dd45a03509df204803de9ecf0b5ff82fd488730f55ac5dd6a23b81dd8" "sha256": "48fd54bd0cdb421badb58f9be2933a039fe3b9350bbe6191070c9f7bb0054670"
}, },
{ {
"path": "scripts/src/types.ts", "path": "scripts/src/types.ts",
+2 -5
View File
@@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url";
import { Command } from "commander"; import { Command } from "commander";
import { resolveFormat } from "./command-helpers.js";
import { createConfluenceClient } from "./confluence.js"; import { createConfluenceClient } from "./confluence.js";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { readWorkspaceFile } from "./files.js"; import { readWorkspaceFile } from "./files.js";
@@ -11,7 +12,7 @@ import { runHealthCheck } from "./health.js";
import { createJiraClient } from "./jira.js"; import { createJiraClient } from "./jira.js";
import { writeOutput } from "./output.js"; import { writeOutput } from "./output.js";
import { runRawCommand } from "./raw.js"; import { runRawCommand } from "./raw.js";
import type { FetchLike, OutputFormat, Writer } from "./types.js"; import type { FetchLike, Writer } from "./types.js";
type CliContext = { type CliContext = {
cwd?: string; cwd?: string;
@@ -21,10 +22,6 @@ type CliContext = {
stderr?: Writer; stderr?: Writer;
}; };
function resolveFormat(format: string | undefined): OutputFormat {
return format === "text" ? "text" : "json";
}
function createRuntime(context: CliContext) { function createRuntime(context: CliContext) {
const cwd = context.cwd ?? process.cwd(); const cwd = context.cwd ?? process.cwd();
const env = context.env ?? process.env; const env = context.env ?? process.env;
@@ -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<T>(data: T): CommandOutput<T> {
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";
}
+4 -21
View File
@@ -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`. // ⚠️ 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 { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
@@ -178,13 +179,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -224,13 +219,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -267,13 +256,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
+5 -28
View File
@@ -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`. // ⚠️ 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 { markdownToAdf } from "./adf.js";
import { dryRunResponse } from "./command-helpers.js";
import { sendJsonRequest } from "./http.js"; import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js";
@@ -162,13 +163,7 @@ export function createJiraClient(options: JiraClientOptions) {
}, },
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", "/rest/api/3/issue", request.body); const raw = await send("POST", "/rest/api/3/issue", request.body);
return { ok: true, data: raw }; return { ok: true, data: raw };
@@ -193,13 +188,7 @@ export function createJiraClient(options: JiraClientOptions) {
fields, fields,
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body);
return { return {
@@ -216,13 +205,7 @@ export function createJiraClient(options: JiraClientOptions) {
body: markdownToAdf(input.body), body: markdownToAdf(input.body),
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body);
return { return {
@@ -243,13 +226,7 @@ export function createJiraClient(options: JiraClientOptions) {
}, },
); );
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body);
return { return {
+2 -7
View File
@@ -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`. // ⚠️ 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 { readWorkspaceFile } from "./files.js";
import { sendJsonRequest } from "./http.js"; import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
@@ -62,13 +63,7 @@ export async function runRawCommand(
...(body === undefined ? {} : { body }), ...(body === undefined ? {} : { body }),
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const data = await sendJsonRequest({ const data = await sendJsonRequest({
config, config,
+2 -5
View File
@@ -3,6 +3,7 @@ import { pathToFileURL } from "node:url";
import { Command } from "commander"; import { Command } from "commander";
import { resolveFormat } from "./command-helpers.js";
import { createConfluenceClient } from "./confluence.js"; import { createConfluenceClient } from "./confluence.js";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { readWorkspaceFile } from "./files.js"; import { readWorkspaceFile } from "./files.js";
@@ -10,7 +11,7 @@ import { runHealthCheck } from "./health.js";
import { createJiraClient } from "./jira.js"; import { createJiraClient } from "./jira.js";
import { writeOutput } from "./output.js"; import { writeOutput } from "./output.js";
import { runRawCommand } from "./raw.js"; import { runRawCommand } from "./raw.js";
import type { FetchLike, OutputFormat, Writer } from "./types.js"; import type { FetchLike, Writer } from "./types.js";
type CliContext = { type CliContext = {
cwd?: string; cwd?: string;
@@ -20,10 +21,6 @@ type CliContext = {
stderr?: Writer; stderr?: Writer;
}; };
function resolveFormat(format: string | undefined): OutputFormat {
return format === "text" ? "text" : "json";
}
function createRuntime(context: CliContext) { function createRuntime(context: CliContext) {
const cwd = context.cwd ?? process.cwd(); const cwd = context.cwd ?? process.cwd();
const env = context.env ?? process.env; const env = context.env ?? process.env;
@@ -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<T>(data: T): CommandOutput<T> {
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";
}
@@ -1,3 +1,4 @@
import { dryRunResponse } from "./command-helpers.js";
import { sendJsonRequest } from "./http.js"; import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
@@ -177,13 +178,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -223,13 +218,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
@@ -266,13 +255,7 @@ export function createConfluenceClient(options: ConfluenceClientOptions) {
}, },
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await sendJsonRequest({ const raw = await sendJsonRequest({
config, config,
+5 -28
View File
@@ -1,4 +1,5 @@
import { markdownToAdf } from "./adf.js"; import { markdownToAdf } from "./adf.js";
import { dryRunResponse } from "./command-helpers.js";
import { sendJsonRequest } from "./http.js"; import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike, JiraIssueSummary } from "./types.js";
@@ -161,13 +162,7 @@ export function createJiraClient(options: JiraClientOptions) {
}, },
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", "/rest/api/3/issue", request.body); const raw = await send("POST", "/rest/api/3/issue", request.body);
return { ok: true, data: raw }; return { ok: true, data: raw };
@@ -192,13 +187,7 @@ export function createJiraClient(options: JiraClientOptions) {
fields, fields,
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body); await send("PUT", `/rest/api/3/issue/${input.issue}`, request.body);
return { return {
@@ -215,13 +204,7 @@ export function createJiraClient(options: JiraClientOptions) {
body: markdownToAdf(input.body), body: markdownToAdf(input.body),
}); });
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body); const raw = await send("POST", `/rest/api/3/issue/${input.issue}/comment`, request.body);
return { return {
@@ -242,13 +225,7 @@ export function createJiraClient(options: JiraClientOptions) {
}, },
); );
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body); await send("POST", `/rest/api/3/issue/${input.issue}/transitions`, request.body);
return { return {
+2 -7
View File
@@ -1,3 +1,4 @@
import { dryRunResponse } from "./command-helpers.js";
import { readWorkspaceFile } from "./files.js"; import { readWorkspaceFile } from "./files.js";
import { sendJsonRequest } from "./http.js"; import { sendJsonRequest } from "./http.js";
import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js"; import type { AtlassianConfig, CommandOutput, FetchLike } from "./types.js";
@@ -61,13 +62,7 @@ export async function runRawCommand(
...(body === undefined ? {} : { body }), ...(body === undefined ? {} : { body }),
}; };
if (input.dryRun) { if (input.dryRun) return dryRunResponse(request);
return {
ok: true,
dryRun: true,
data: request,
};
}
const data = await sendJsonRequest({ const data = await sendJsonRequest({
config, config,
@@ -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");
});
@@ -7,13 +7,13 @@
"path": "scripts/auth.ts", "path": "scripts/auth.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "ce0a8aae0bc41b86e11aab51cc0e0cfa484a1934807f147c05c9bd38d416c066" "sha256": "c0940f452437b05b95e58a9a7ab265fb50aa412bd672e82fedd6a37cbfb3d505"
}, },
{ {
"path": "scripts/browse.ts", "path": "scripts/browse.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "42da9cdc6806b8d7d8d814952ad9540033b6c6a4cbe9844ada328b2ceace67c9" "sha256": "d7e4b4c50116032e5a00f90bca27e069dfc5bbf6eeb06ec8f8edc9e5a9792ab8"
}, },
{ {
"path": "scripts/check-install.js", "path": "scripts/check-install.js",
@@ -31,7 +31,13 @@
"path": "scripts/flow.ts", "path": "scripts/flow.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "b1c256bf6a206473512a4c0555c891893a48025529da282fa6cd07e68ad3d051" "sha256": "94f3e7987cab253dc3c9e80656a11759fada13b3915608bff7ae08418602f366"
},
{
"path": "scripts/lib/browser.ts",
"kind": "file",
"mode": "644",
"sha256": "879b5f883ff1f888d45ed20be05c2d9bc3d6fe5305a1972b7d49a7e6c0e24934"
}, },
{ {
"path": "scripts/package.json", "path": "scripts/package.json",
@@ -49,7 +55,7 @@
"path": "scripts/scan-local-app.ts", "path": "scripts/scan-local-app.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "3f42f9bb2d355fefc8645d2b2acfa3107bd87f9c2579b2631c94132bed0abea4" "sha256": "9e1818c254a633e087715609152936dcb3613a0aa724d40a8a13460510691dc7"
}, },
{ {
"path": "scripts/scrape.ts", "path": "scripts/scrape.ts",
@@ -79,7 +85,7 @@
"path": "scripts/tsconfig.json", "path": "scripts/tsconfig.json",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "5f9a83c8caab167eb20defbb5afde58f2bb573a300af99654997dcb3372408e0" "sha256": "e5f22d72266068cf410976c880511f2ec1875445256e11739a5e1de6ffedf38d"
}, },
{ {
"path": "scripts/turndown-plugin-gfm.d.ts", "path": "scripts/turndown-plugin-gfm.d.ts",
@@ -11,7 +11,7 @@
* npx tsx auth.ts --url "https://example.com" --type auto * 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 parseArgs from 'minimist';
import type { Page, BrowserContext } from 'playwright-core'; import type { Page, BrowserContext } from 'playwright-core';
import { createInterface } from 'readline'; import { createInterface } from 'readline';
@@ -10,12 +10,13 @@
* npx tsx browse.ts --url "https://example.com" --headless false --wait 5000 * 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 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 { interface BrowseOptions {
url: string; url: string;
@@ -37,36 +38,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); 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<BrowserContext> {
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<BrowseResult> { export async function browse(options: BrowseOptions): Promise<BrowseResult> {
const browser = await launchBrowser({ headless: options.headless }); const browser = await launchBrowser({ headless: options.headless });
const page = browser.pages()[0] || await browser.newPage(); const page = browser.pages()[0] || await browser.newPage();
@@ -112,14 +83,6 @@ export async function browse(options: BrowseOptions): Promise<BrowseResult> {
} }
} }
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() { async function main() {
const args = parseArgs(process.argv.slice(2), { const args = parseArgs(process.argv.slice(2), {
string: ['url', 'output'], string: ['url', 'output'],
@@ -3,7 +3,7 @@
import parseArgs from 'minimist'; import parseArgs from 'minimist';
import type { Page } from 'playwright-core'; import type { Page } from 'playwright-core';
import { launchBrowser } from './browse'; import { launchBrowser } from './lib/browser.js';
type Step = type Step =
| { action: 'goto'; url: string } | { action: 'goto'; url: string }
@@ -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<BrowserContext> {
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 };
}
@@ -3,7 +3,8 @@
import { mkdirSync, writeFileSync } from 'fs'; import { mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';
import { getPage } from './browse.js'; import type { Page } from 'playwright-core';
import { getPage } from './lib/browser.js';
type NavResult = { type NavResult = {
requestedUrl: string; requestedUrl: string;
@@ -40,30 +41,34 @@ function getRoutes(baseUrl: string): string[] {
return [baseUrl]; return [baseUrl];
} }
async function gotoWithStatus(page: any, url: string): Promise<NavResult> { type GotoError = { error: unknown };
async function gotoWithStatus(page: Page, url: string): Promise<NavResult> {
const response = await page const response = await page
.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) .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 { return {
requestedUrl: url, requestedUrl: url,
url: page.url(), url: page.url(),
status: null, status: null,
title: await page.title().catch(() => ''), title: await page.title().catch(() => ''),
error: String(response.error), error: String(gotoError.error),
}; };
} }
const httpResponse = response as Awaited<ReturnType<Page['goto']>>;
return { return {
requestedUrl: url, requestedUrl: url,
url: page.url(), url: page.url(),
status: response ? response.status() : null, status: httpResponse ? httpResponse.status() : null,
title: await page.title().catch(() => ''), title: await page.title().catch(() => ''),
}; };
} }
async function textOrNull(page: any, selector: string): Promise<string | null> { async function textOrNull(page: Page, selector: string): Promise<string | null> {
const locator = page.locator(selector).first(); const locator = page.locator(selector).first();
try { try {
if ((await locator.count()) === 0) return null; if ((await locator.count()) === 0) return null;
@@ -74,7 +79,7 @@ async function textOrNull(page: any, selector: string): Promise<string | null> {
} }
} }
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 loginPath = env('SCAN_LOGIN_PATH');
const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME');
const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD');
@@ -110,7 +115,7 @@ async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) {
lines.push(''); 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 routes = getRoutes(baseUrl);
const routeChecks: RouteCheck[] = []; const routeChecks: RouteCheck[] = [];
@@ -11,6 +11,6 @@
"outDir": "./dist", "outDir": "./dist",
"rootDir": "." "rootDir": "."
}, },
"include": ["*.ts"], "include": ["*.ts", "lib/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }
@@ -7,13 +7,13 @@
"path": "scripts/auth.ts", "path": "scripts/auth.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "ce0a8aae0bc41b86e11aab51cc0e0cfa484a1934807f147c05c9bd38d416c066" "sha256": "c0940f452437b05b95e58a9a7ab265fb50aa412bd672e82fedd6a37cbfb3d505"
}, },
{ {
"path": "scripts/browse.ts", "path": "scripts/browse.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "42da9cdc6806b8d7d8d814952ad9540033b6c6a4cbe9844ada328b2ceace67c9" "sha256": "d7e4b4c50116032e5a00f90bca27e069dfc5bbf6eeb06ec8f8edc9e5a9792ab8"
}, },
{ {
"path": "scripts/check-install.js", "path": "scripts/check-install.js",
@@ -31,7 +31,13 @@
"path": "scripts/flow.ts", "path": "scripts/flow.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "b1c256bf6a206473512a4c0555c891893a48025529da282fa6cd07e68ad3d051" "sha256": "94f3e7987cab253dc3c9e80656a11759fada13b3915608bff7ae08418602f366"
},
{
"path": "scripts/lib/browser.ts",
"kind": "file",
"mode": "644",
"sha256": "879b5f883ff1f888d45ed20be05c2d9bc3d6fe5305a1972b7d49a7e6c0e24934"
}, },
{ {
"path": "scripts/package.json", "path": "scripts/package.json",
@@ -49,7 +55,7 @@
"path": "scripts/scan-local-app.ts", "path": "scripts/scan-local-app.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "3f42f9bb2d355fefc8645d2b2acfa3107bd87f9c2579b2631c94132bed0abea4" "sha256": "9e1818c254a633e087715609152936dcb3613a0aa724d40a8a13460510691dc7"
}, },
{ {
"path": "scripts/scrape.ts", "path": "scripts/scrape.ts",
@@ -79,7 +85,7 @@
"path": "scripts/tsconfig.json", "path": "scripts/tsconfig.json",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "5f9a83c8caab167eb20defbb5afde58f2bb573a300af99654997dcb3372408e0" "sha256": "e5f22d72266068cf410976c880511f2ec1875445256e11739a5e1de6ffedf38d"
}, },
{ {
"path": "scripts/turndown-plugin-gfm.d.ts", "path": "scripts/turndown-plugin-gfm.d.ts",
+1 -1
View File
@@ -11,7 +11,7 @@
* npx tsx auth.ts --url "https://example.com" --type auto * 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 parseArgs from 'minimist';
import type { Page, BrowserContext } from 'playwright-core'; import type { Page, BrowserContext } from 'playwright-core';
import { createInterface } from 'readline'; import { createInterface } from 'readline';
+6 -43
View File
@@ -10,12 +10,13 @@
* npx tsx browse.ts --url "https://example.com" --headless false --wait 5000 * 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 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 { interface BrowseOptions {
url: string; url: string;
@@ -37,36 +38,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); 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<BrowserContext> {
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<BrowseResult> { export async function browse(options: BrowseOptions): Promise<BrowseResult> {
const browser = await launchBrowser({ headless: options.headless }); const browser = await launchBrowser({ headless: options.headless });
const page = browser.pages()[0] || await browser.newPage(); const page = browser.pages()[0] || await browser.newPage();
@@ -112,14 +83,6 @@ export async function browse(options: BrowseOptions): Promise<BrowseResult> {
} }
} }
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() { async function main() {
const args = parseArgs(process.argv.slice(2), { const args = parseArgs(process.argv.slice(2), {
string: ['url', 'output'], string: ['url', 'output'],
+1 -1
View File
@@ -3,7 +3,7 @@
import parseArgs from 'minimist'; import parseArgs from 'minimist';
import type { Page } from 'playwright-core'; import type { Page } from 'playwright-core';
import { launchBrowser } from './browse'; import { launchBrowser } from './lib/browser.js';
type Step = type Step =
| { action: 'goto'; url: string } | { action: 'goto'; url: string }
@@ -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<BrowserContext> {
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 };
}
@@ -3,7 +3,8 @@
import { mkdirSync, writeFileSync } from 'fs'; import { mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';
import { getPage } from './browse.js'; import type { Page } from 'playwright-core';
import { getPage } from './lib/browser.js';
type NavResult = { type NavResult = {
requestedUrl: string; requestedUrl: string;
@@ -40,30 +41,34 @@ function getRoutes(baseUrl: string): string[] {
return [baseUrl]; return [baseUrl];
} }
async function gotoWithStatus(page: any, url: string): Promise<NavResult> { type GotoError = { error: unknown };
async function gotoWithStatus(page: Page, url: string): Promise<NavResult> {
const response = await page const response = await page
.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) .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 { return {
requestedUrl: url, requestedUrl: url,
url: page.url(), url: page.url(),
status: null, status: null,
title: await page.title().catch(() => ''), title: await page.title().catch(() => ''),
error: String(response.error), error: String(gotoError.error),
}; };
} }
const httpResponse = response as Awaited<ReturnType<Page['goto']>>;
return { return {
requestedUrl: url, requestedUrl: url,
url: page.url(), url: page.url(),
status: response ? response.status() : null, status: httpResponse ? httpResponse.status() : null,
title: await page.title().catch(() => ''), title: await page.title().catch(() => ''),
}; };
} }
async function textOrNull(page: any, selector: string): Promise<string | null> { async function textOrNull(page: Page, selector: string): Promise<string | null> {
const locator = page.locator(selector).first(); const locator = page.locator(selector).first();
try { try {
if ((await locator.count()) === 0) return null; if ((await locator.count()) === 0) return null;
@@ -74,7 +79,7 @@ async function textOrNull(page: any, selector: string): Promise<string | null> {
} }
} }
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 loginPath = env('SCAN_LOGIN_PATH');
const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME');
const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD');
@@ -110,7 +115,7 @@ async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) {
lines.push(''); 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 routes = getRoutes(baseUrl);
const routeChecks: RouteCheck[] = []; const routeChecks: RouteCheck[] = [];
@@ -11,6 +11,6 @@
"outDir": "./dist", "outDir": "./dist",
"rootDir": "." "rootDir": "."
}, },
"include": ["*.ts"], "include": ["*.ts", "lib/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }
@@ -7,13 +7,13 @@
"path": "scripts/auth.ts", "path": "scripts/auth.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "ce0a8aae0bc41b86e11aab51cc0e0cfa484a1934807f147c05c9bd38d416c066" "sha256": "c0940f452437b05b95e58a9a7ab265fb50aa412bd672e82fedd6a37cbfb3d505"
}, },
{ {
"path": "scripts/browse.ts", "path": "scripts/browse.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "42da9cdc6806b8d7d8d814952ad9540033b6c6a4cbe9844ada328b2ceace67c9" "sha256": "d7e4b4c50116032e5a00f90bca27e069dfc5bbf6eeb06ec8f8edc9e5a9792ab8"
}, },
{ {
"path": "scripts/check-install.js", "path": "scripts/check-install.js",
@@ -31,7 +31,13 @@
"path": "scripts/flow.ts", "path": "scripts/flow.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "b1c256bf6a206473512a4c0555c891893a48025529da282fa6cd07e68ad3d051" "sha256": "94f3e7987cab253dc3c9e80656a11759fada13b3915608bff7ae08418602f366"
},
{
"path": "scripts/lib/browser.ts",
"kind": "file",
"mode": "644",
"sha256": "879b5f883ff1f888d45ed20be05c2d9bc3d6fe5305a1972b7d49a7e6c0e24934"
}, },
{ {
"path": "scripts/package.json", "path": "scripts/package.json",
@@ -49,7 +55,7 @@
"path": "scripts/scan-local-app.ts", "path": "scripts/scan-local-app.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "3f42f9bb2d355fefc8645d2b2acfa3107bd87f9c2579b2631c94132bed0abea4" "sha256": "9e1818c254a633e087715609152936dcb3613a0aa724d40a8a13460510691dc7"
}, },
{ {
"path": "scripts/scrape.ts", "path": "scripts/scrape.ts",
@@ -79,7 +85,7 @@
"path": "scripts/tsconfig.json", "path": "scripts/tsconfig.json",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "5f9a83c8caab167eb20defbb5afde58f2bb573a300af99654997dcb3372408e0" "sha256": "e5f22d72266068cf410976c880511f2ec1875445256e11739a5e1de6ffedf38d"
}, },
{ {
"path": "scripts/turndown-plugin-gfm.d.ts", "path": "scripts/turndown-plugin-gfm.d.ts",
+1 -1
View File
@@ -11,7 +11,7 @@
* npx tsx auth.ts --url "https://example.com" --type auto * 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 parseArgs from 'minimist';
import type { Page, BrowserContext } from 'playwright-core'; import type { Page, BrowserContext } from 'playwright-core';
import { createInterface } from 'readline'; import { createInterface } from 'readline';
+6 -43
View File
@@ -10,12 +10,13 @@
* npx tsx browse.ts --url "https://example.com" --headless false --wait 5000 * 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 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 { interface BrowseOptions {
url: string; url: string;
@@ -37,36 +38,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); 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<BrowserContext> {
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<BrowseResult> { export async function browse(options: BrowseOptions): Promise<BrowseResult> {
const browser = await launchBrowser({ headless: options.headless }); const browser = await launchBrowser({ headless: options.headless });
const page = browser.pages()[0] || await browser.newPage(); const page = browser.pages()[0] || await browser.newPage();
@@ -112,14 +83,6 @@ export async function browse(options: BrowseOptions): Promise<BrowseResult> {
} }
} }
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() { async function main() {
const args = parseArgs(process.argv.slice(2), { const args = parseArgs(process.argv.slice(2), {
string: ['url', 'output'], string: ['url', 'output'],
+1 -1
View File
@@ -3,7 +3,7 @@
import parseArgs from 'minimist'; import parseArgs from 'minimist';
import type { Page } from 'playwright-core'; import type { Page } from 'playwright-core';
import { launchBrowser } from './browse'; import { launchBrowser } from './lib/browser.js';
type Step = type Step =
| { action: 'goto'; url: string } | { action: 'goto'; url: string }
@@ -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<BrowserContext> {
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 };
}
@@ -3,7 +3,8 @@
import { mkdirSync, writeFileSync } from 'fs'; import { mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';
import { getPage } from './browse.js'; import type { Page } from 'playwright-core';
import { getPage } from './lib/browser.js';
type NavResult = { type NavResult = {
requestedUrl: string; requestedUrl: string;
@@ -40,30 +41,34 @@ function getRoutes(baseUrl: string): string[] {
return [baseUrl]; return [baseUrl];
} }
async function gotoWithStatus(page: any, url: string): Promise<NavResult> { type GotoError = { error: unknown };
async function gotoWithStatus(page: Page, url: string): Promise<NavResult> {
const response = await page const response = await page
.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) .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 { return {
requestedUrl: url, requestedUrl: url,
url: page.url(), url: page.url(),
status: null, status: null,
title: await page.title().catch(() => ''), title: await page.title().catch(() => ''),
error: String(response.error), error: String(gotoError.error),
}; };
} }
const httpResponse = response as Awaited<ReturnType<Page['goto']>>;
return { return {
requestedUrl: url, requestedUrl: url,
url: page.url(), url: page.url(),
status: response ? response.status() : null, status: httpResponse ? httpResponse.status() : null,
title: await page.title().catch(() => ''), title: await page.title().catch(() => ''),
}; };
} }
async function textOrNull(page: any, selector: string): Promise<string | null> { async function textOrNull(page: Page, selector: string): Promise<string | null> {
const locator = page.locator(selector).first(); const locator = page.locator(selector).first();
try { try {
if ((await locator.count()) === 0) return null; if ((await locator.count()) === 0) return null;
@@ -74,7 +79,7 @@ async function textOrNull(page: any, selector: string): Promise<string | null> {
} }
} }
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 loginPath = env('SCAN_LOGIN_PATH');
const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME');
const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD');
@@ -110,7 +115,7 @@ async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) {
lines.push(''); 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 routes = getRoutes(baseUrl);
const routeChecks: RouteCheck[] = []; const routeChecks: RouteCheck[] = [];
@@ -11,6 +11,6 @@
"outDir": "./dist", "outDir": "./dist",
"rootDir": "." "rootDir": "."
}, },
"include": ["*.ts"], "include": ["*.ts", "lib/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }
@@ -7,13 +7,13 @@
"path": "scripts/auth.ts", "path": "scripts/auth.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "ce0a8aae0bc41b86e11aab51cc0e0cfa484a1934807f147c05c9bd38d416c066" "sha256": "c0940f452437b05b95e58a9a7ab265fb50aa412bd672e82fedd6a37cbfb3d505"
}, },
{ {
"path": "scripts/browse.ts", "path": "scripts/browse.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "42da9cdc6806b8d7d8d814952ad9540033b6c6a4cbe9844ada328b2ceace67c9" "sha256": "d7e4b4c50116032e5a00f90bca27e069dfc5bbf6eeb06ec8f8edc9e5a9792ab8"
}, },
{ {
"path": "scripts/check-install.js", "path": "scripts/check-install.js",
@@ -31,7 +31,13 @@
"path": "scripts/flow.ts", "path": "scripts/flow.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "b1c256bf6a206473512a4c0555c891893a48025529da282fa6cd07e68ad3d051" "sha256": "94f3e7987cab253dc3c9e80656a11759fada13b3915608bff7ae08418602f366"
},
{
"path": "scripts/lib/browser.ts",
"kind": "file",
"mode": "644",
"sha256": "879b5f883ff1f888d45ed20be05c2d9bc3d6fe5305a1972b7d49a7e6c0e24934"
}, },
{ {
"path": "scripts/package.json", "path": "scripts/package.json",
@@ -49,7 +55,7 @@
"path": "scripts/scan-local-app.ts", "path": "scripts/scan-local-app.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "3f42f9bb2d355fefc8645d2b2acfa3107bd87f9c2579b2631c94132bed0abea4" "sha256": "9e1818c254a633e087715609152936dcb3613a0aa724d40a8a13460510691dc7"
}, },
{ {
"path": "scripts/scrape.ts", "path": "scripts/scrape.ts",
@@ -79,7 +85,7 @@
"path": "scripts/tsconfig.json", "path": "scripts/tsconfig.json",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "5f9a83c8caab167eb20defbb5afde58f2bb573a300af99654997dcb3372408e0" "sha256": "e5f22d72266068cf410976c880511f2ec1875445256e11739a5e1de6ffedf38d"
}, },
{ {
"path": "scripts/turndown-plugin-gfm.d.ts", "path": "scripts/turndown-plugin-gfm.d.ts",
@@ -11,7 +11,7 @@
* npx tsx auth.ts --url "https://example.com" --type auto * 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 parseArgs from 'minimist';
import type { Page, BrowserContext } from 'playwright-core'; import type { Page, BrowserContext } from 'playwright-core';
import { createInterface } from 'readline'; import { createInterface } from 'readline';
@@ -10,12 +10,13 @@
* npx tsx browse.ts --url "https://example.com" --headless false --wait 5000 * 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 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 { interface BrowseOptions {
url: string; url: string;
@@ -37,36 +38,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); 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<BrowserContext> {
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<BrowseResult> { export async function browse(options: BrowseOptions): Promise<BrowseResult> {
const browser = await launchBrowser({ headless: options.headless }); const browser = await launchBrowser({ headless: options.headless });
const page = browser.pages()[0] || await browser.newPage(); const page = browser.pages()[0] || await browser.newPage();
@@ -112,14 +83,6 @@ export async function browse(options: BrowseOptions): Promise<BrowseResult> {
} }
} }
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() { async function main() {
const args = parseArgs(process.argv.slice(2), { const args = parseArgs(process.argv.slice(2), {
string: ['url', 'output'], string: ['url', 'output'],
@@ -3,7 +3,7 @@
import parseArgs from 'minimist'; import parseArgs from 'minimist';
import type { Page } from 'playwright-core'; import type { Page } from 'playwright-core';
import { launchBrowser } from './browse'; import { launchBrowser } from './lib/browser.js';
type Step = type Step =
| { action: 'goto'; url: string } | { action: 'goto'; url: string }
@@ -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<BrowserContext> {
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 };
}
@@ -3,7 +3,8 @@
import { mkdirSync, writeFileSync } from 'fs'; import { mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';
import { getPage } from './browse.js'; import type { Page } from 'playwright-core';
import { getPage } from './lib/browser.js';
type NavResult = { type NavResult = {
requestedUrl: string; requestedUrl: string;
@@ -40,30 +41,34 @@ function getRoutes(baseUrl: string): string[] {
return [baseUrl]; return [baseUrl];
} }
async function gotoWithStatus(page: any, url: string): Promise<NavResult> { type GotoError = { error: unknown };
async function gotoWithStatus(page: Page, url: string): Promise<NavResult> {
const response = await page const response = await page
.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) .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 { return {
requestedUrl: url, requestedUrl: url,
url: page.url(), url: page.url(),
status: null, status: null,
title: await page.title().catch(() => ''), title: await page.title().catch(() => ''),
error: String(response.error), error: String(gotoError.error),
}; };
} }
const httpResponse = response as Awaited<ReturnType<Page['goto']>>;
return { return {
requestedUrl: url, requestedUrl: url,
url: page.url(), url: page.url(),
status: response ? response.status() : null, status: httpResponse ? httpResponse.status() : null,
title: await page.title().catch(() => ''), title: await page.title().catch(() => ''),
}; };
} }
async function textOrNull(page: any, selector: string): Promise<string | null> { async function textOrNull(page: Page, selector: string): Promise<string | null> {
const locator = page.locator(selector).first(); const locator = page.locator(selector).first();
try { try {
if ((await locator.count()) === 0) return null; if ((await locator.count()) === 0) return null;
@@ -74,7 +79,7 @@ async function textOrNull(page: any, selector: string): Promise<string | null> {
} }
} }
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 loginPath = env('SCAN_LOGIN_PATH');
const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME');
const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD');
@@ -110,7 +115,7 @@ async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) {
lines.push(''); 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 routes = getRoutes(baseUrl);
const routeChecks: RouteCheck[] = []; const routeChecks: RouteCheck[] = [];
@@ -11,6 +11,6 @@
"outDir": "./dist", "outDir": "./dist",
"rootDir": "." "rootDir": "."
}, },
"include": ["*.ts"], "include": ["*.ts", "lib/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }
@@ -7,13 +7,13 @@
"path": "scripts/auth.ts", "path": "scripts/auth.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "ce0a8aae0bc41b86e11aab51cc0e0cfa484a1934807f147c05c9bd38d416c066" "sha256": "c0940f452437b05b95e58a9a7ab265fb50aa412bd672e82fedd6a37cbfb3d505"
}, },
{ {
"path": "scripts/browse.ts", "path": "scripts/browse.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "42da9cdc6806b8d7d8d814952ad9540033b6c6a4cbe9844ada328b2ceace67c9" "sha256": "d7e4b4c50116032e5a00f90bca27e069dfc5bbf6eeb06ec8f8edc9e5a9792ab8"
}, },
{ {
"path": "scripts/check-install.js", "path": "scripts/check-install.js",
@@ -31,7 +31,13 @@
"path": "scripts/flow.ts", "path": "scripts/flow.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "b1c256bf6a206473512a4c0555c891893a48025529da282fa6cd07e68ad3d051" "sha256": "94f3e7987cab253dc3c9e80656a11759fada13b3915608bff7ae08418602f366"
},
{
"path": "scripts/lib/browser.ts",
"kind": "file",
"mode": "644",
"sha256": "879b5f883ff1f888d45ed20be05c2d9bc3d6fe5305a1972b7d49a7e6c0e24934"
}, },
{ {
"path": "scripts/package.json", "path": "scripts/package.json",
@@ -49,7 +55,7 @@
"path": "scripts/scan-local-app.ts", "path": "scripts/scan-local-app.ts",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "3f42f9bb2d355fefc8645d2b2acfa3107bd87f9c2579b2631c94132bed0abea4" "sha256": "9e1818c254a633e087715609152936dcb3613a0aa724d40a8a13460510691dc7"
}, },
{ {
"path": "scripts/scrape.ts", "path": "scripts/scrape.ts",
@@ -79,7 +85,7 @@
"path": "scripts/tsconfig.json", "path": "scripts/tsconfig.json",
"kind": "file", "kind": "file",
"mode": "644", "mode": "644",
"sha256": "5f9a83c8caab167eb20defbb5afde58f2bb573a300af99654997dcb3372408e0" "sha256": "e5f22d72266068cf410976c880511f2ec1875445256e11739a5e1de6ffedf38d"
}, },
{ {
"path": "scripts/turndown-plugin-gfm.d.ts", "path": "scripts/turndown-plugin-gfm.d.ts",
+1 -1
View File
@@ -11,7 +11,7 @@
* npx tsx auth.ts --url "https://example.com" --type auto * 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 parseArgs from 'minimist';
import type { Page, BrowserContext } from 'playwright-core'; import type { Page, BrowserContext } from 'playwright-core';
import { createInterface } from 'readline'; import { createInterface } from 'readline';
+6 -43
View File
@@ -10,12 +10,13 @@
* npx tsx browse.ts --url "https://example.com" --headless false --wait 5000 * 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 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 { interface BrowseOptions {
url: string; url: string;
@@ -37,36 +38,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); 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<BrowserContext> {
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<BrowseResult> { export async function browse(options: BrowseOptions): Promise<BrowseResult> {
const browser = await launchBrowser({ headless: options.headless }); const browser = await launchBrowser({ headless: options.headless });
const page = browser.pages()[0] || await browser.newPage(); const page = browser.pages()[0] || await browser.newPage();
@@ -112,14 +83,6 @@ export async function browse(options: BrowseOptions): Promise<BrowseResult> {
} }
} }
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() { async function main() {
const args = parseArgs(process.argv.slice(2), { const args = parseArgs(process.argv.slice(2), {
string: ['url', 'output'], string: ['url', 'output'],
+1 -1
View File
@@ -3,7 +3,7 @@
import parseArgs from 'minimist'; import parseArgs from 'minimist';
import type { Page } from 'playwright-core'; import type { Page } from 'playwright-core';
import { launchBrowser } from './browse'; import { launchBrowser } from './lib/browser.js';
type Step = type Step =
| { action: 'goto'; url: string } | { action: 'goto'; url: string }
@@ -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<BrowserContext> {
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 };
}
@@ -3,7 +3,8 @@
import { mkdirSync, writeFileSync } from 'fs'; import { mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';
import { getPage } from './browse.js'; import type { Page } from 'playwright-core';
import { getPage } from './lib/browser.js';
type NavResult = { type NavResult = {
requestedUrl: string; requestedUrl: string;
@@ -40,30 +41,34 @@ function getRoutes(baseUrl: string): string[] {
return [baseUrl]; return [baseUrl];
} }
async function gotoWithStatus(page: any, url: string): Promise<NavResult> { type GotoError = { error: unknown };
async function gotoWithStatus(page: Page, url: string): Promise<NavResult> {
const response = await page const response = await page
.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) .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 { return {
requestedUrl: url, requestedUrl: url,
url: page.url(), url: page.url(),
status: null, status: null,
title: await page.title().catch(() => ''), title: await page.title().catch(() => ''),
error: String(response.error), error: String(gotoError.error),
}; };
} }
const httpResponse = response as Awaited<ReturnType<Page['goto']>>;
return { return {
requestedUrl: url, requestedUrl: url,
url: page.url(), url: page.url(),
status: response ? response.status() : null, status: httpResponse ? httpResponse.status() : null,
title: await page.title().catch(() => ''), title: await page.title().catch(() => ''),
}; };
} }
async function textOrNull(page: any, selector: string): Promise<string | null> { async function textOrNull(page: Page, selector: string): Promise<string | null> {
const locator = page.locator(selector).first(); const locator = page.locator(selector).first();
try { try {
if ((await locator.count()) === 0) return null; if ((await locator.count()) === 0) return null;
@@ -74,7 +79,7 @@ async function textOrNull(page: any, selector: string): Promise<string | null> {
} }
} }
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 loginPath = env('SCAN_LOGIN_PATH');
const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME');
const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD');
@@ -110,7 +115,7 @@ async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) {
lines.push(''); 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 routes = getRoutes(baseUrl);
const routeChecks: RouteCheck[] = []; const routeChecks: RouteCheck[] = [];
@@ -11,6 +11,6 @@
"outDir": "./dist", "outDir": "./dist",
"rootDir": "." "rootDir": "."
}, },
"include": ["*.ts"], "include": ["*.ts", "lib/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }
+1 -1
View File
@@ -10,7 +10,7 @@
* npx tsx auth.ts --url "https://example.com" --type auto * 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 parseArgs from 'minimist';
import type { Page, BrowserContext } from 'playwright-core'; import type { Page, BrowserContext } from 'playwright-core';
import { createInterface } from 'readline'; import { createInterface } from 'readline';
+6 -43
View File
@@ -9,12 +9,13 @@
* npx tsx browse.ts --url "https://example.com" --headless false --wait 5000 * 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 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 { interface BrowseOptions {
url: string; url: string;
@@ -36,36 +37,6 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); 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<BrowserContext> {
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<BrowseResult> { export async function browse(options: BrowseOptions): Promise<BrowseResult> {
const browser = await launchBrowser({ headless: options.headless }); const browser = await launchBrowser({ headless: options.headless });
const page = browser.pages()[0] || await browser.newPage(); const page = browser.pages()[0] || await browser.newPage();
@@ -111,14 +82,6 @@ export async function browse(options: BrowseOptions): Promise<BrowseResult> {
} }
} }
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() { async function main() {
const args = parseArgs(process.argv.slice(2), { const args = parseArgs(process.argv.slice(2), {
string: ['url', 'output'], string: ['url', 'output'],
+1 -1
View File
@@ -2,7 +2,7 @@
import parseArgs from 'minimist'; import parseArgs from 'minimist';
import type { Page } from 'playwright-core'; import type { Page } from 'playwright-core';
import { launchBrowser } from './browse'; import { launchBrowser } from './lib/browser.js';
type Step = type Step =
| { action: 'goto'; url: string } | { action: 'goto'; url: string }
@@ -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<BrowserContext> {
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 };
}
+14 -9
View File
@@ -2,7 +2,8 @@
import { mkdirSync, writeFileSync } from 'fs'; import { mkdirSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';
import { getPage } from './browse.js'; import type { Page } from 'playwright-core';
import { getPage } from './lib/browser.js';
type NavResult = { type NavResult = {
requestedUrl: string; requestedUrl: string;
@@ -39,30 +40,34 @@ function getRoutes(baseUrl: string): string[] {
return [baseUrl]; return [baseUrl];
} }
async function gotoWithStatus(page: any, url: string): Promise<NavResult> { type GotoError = { error: unknown };
async function gotoWithStatus(page: Page, url: string): Promise<NavResult> {
const response = await page const response = await page
.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }) .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 { return {
requestedUrl: url, requestedUrl: url,
url: page.url(), url: page.url(),
status: null, status: null,
title: await page.title().catch(() => ''), title: await page.title().catch(() => ''),
error: String(response.error), error: String(gotoError.error),
}; };
} }
const httpResponse = response as Awaited<ReturnType<Page['goto']>>;
return { return {
requestedUrl: url, requestedUrl: url,
url: page.url(), url: page.url(),
status: response ? response.status() : null, status: httpResponse ? httpResponse.status() : null,
title: await page.title().catch(() => ''), title: await page.title().catch(() => ''),
}; };
} }
async function textOrNull(page: any, selector: string): Promise<string | null> { async function textOrNull(page: Page, selector: string): Promise<string | null> {
const locator = page.locator(selector).first(); const locator = page.locator(selector).first();
try { try {
if ((await locator.count()) === 0) return null; if ((await locator.count()) === 0) return null;
@@ -73,7 +78,7 @@ async function textOrNull(page: any, selector: string): Promise<string | null> {
} }
} }
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 loginPath = env('SCAN_LOGIN_PATH');
const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME'); const username = env('SCAN_USERNAME') ?? env('CLOAKBROWSER_USERNAME');
const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD'); const password = env('SCAN_PASSWORD') ?? env('CLOAKBROWSER_PASSWORD');
@@ -109,7 +114,7 @@ async function loginIfConfigured(page: any, baseUrl: string, lines: string[]) {
lines.push(''); 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 routes = getRoutes(baseUrl);
const routeChecks: RouteCheck[] = []; const routeChecks: RouteCheck[] = [];
+1 -1
View File
@@ -11,6 +11,6 @@
"outDir": "./dist", "outDir": "./dist",
"rootDir": "." "rootDir": "."
}, },
"include": ["*.ts"], "include": ["*.ts", "lib/**/*.ts"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }