@pagepocket/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @pagepocket/cli
2
2
 
3
3
  CLI for capturing offline snapshots of web pages using the PagePocket library and
4
- NetworkInterceptorAdapter event streams.
4
+ plugin-owned HTTP capture.
5
5
 
6
6
  ## Install
7
7
 
@@ -12,15 +12,68 @@ npm i -g @pagepocket/cli
12
12
  ## Usage
13
13
 
14
14
  ```bash
15
- pp https://example.com
16
- pp https://example.com -o ./snapshots
17
- pp https://example.com -o ./snapshots --overwrite
15
+ pp archive https://example.com
16
+ pp archive https://example.com -o ./snapshots
17
+ pp archive https://example.com -o ./snapshots --overwrite
18
18
  ```
19
19
 
20
+ ## Configuration
21
+
22
+ PagePocket CLI stores a persistent config file under your user config directory:
23
+
24
+ - Linux: `~/.config/pagepocket/config.json`
25
+ - Windows: `%APPDATA%\\pagepocket\\config.json`
26
+
27
+ Initial config:
28
+
29
+ ```json
30
+ {
31
+ "plugins": []
32
+ }
33
+ ```
34
+
35
+ ## Plugins
36
+
37
+ Manage configured plugins:
38
+
39
+ ```bash
40
+ pp plugin ls
41
+ pp plugin add <plugin-name>
42
+ pp plugin remove <plugin-name>
43
+ ```
44
+
45
+ Options passing:
46
+
47
+ ```bash
48
+ # no options
49
+ pp plugin add @scope/plugin
50
+
51
+ # object options (becomes { options: { arg1: "xxx" } })
52
+ pp plugin add @scope/plugin --option-arg1 xxx --option-arg2 yyy
53
+
54
+ # array options (becomes { options: ["1", "2"] })
55
+ pp plugin add @scope/plugin --option 1 --option 2
56
+ ```
57
+
58
+ Notes:
59
+
60
+ - `pp plugin remove` only removes the entry from `config.json` (it does not uninstall the package).
61
+ - During `pp archive ...`, the CLI reads `config.json` and tries to load all configured plugins.
62
+ If a plugin fails to load, it will be skipped and the archive will continue.
63
+
20
64
  ## Output
21
65
 
22
- Snapshots are written to a folder named after the page title (or `snapshot`) inside
23
- the output directory (default: current directory). Example layout:
66
+ Snapshots are written to a folder named after the page title inside the output
67
+ directory (default: current directory).
68
+
69
+ - The title is sanitized for safe filesystem usage.
70
+ - If the title is missing/blank, we fall back to the page hostname, then `snapshot`.
71
+ - If the target path already exists and `--overwrite` is not set, we de-dupe by
72
+ appending `-2`, `-3`, ...
73
+
74
+ For `--emit zip`, the output is written as `<output>/<title>.zip`.
75
+
76
+ Example layout:
24
77
 
25
78
  ```
26
79
  <output>/<title>/index.html
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const core_1 = require("@oclif/core");
7
+ const build_snapshot_unit_1 = require("@pagepocket/build-snapshot-unit");
8
+ const capture_http_cdp_unit_1 = require("@pagepocket/capture-http-cdp-unit");
9
+ const capture_http_lighterceptor_unit_1 = require("@pagepocket/capture-http-lighterceptor-unit");
10
+ const capture_http_puppeteer_unit_1 = require("@pagepocket/capture-http-puppeteer-unit");
11
+ const lib_1 = require("@pagepocket/lib");
12
+ const single_file_unit_1 = require("@pagepocket/single-file-unit");
13
+ const write_down_unit_1 = require("@pagepocket/write-down-unit");
14
+ const chalk_1 = __importDefault(require("chalk"));
15
+ const network_observer_unit_1 = require("../units/network-observer-unit");
16
+ const with_spinner_1 = require("../utils/with-spinner");
17
+ const load_configured_plugins_1 = require("../services/load-configured-plugins");
18
+ const MAX_STATUS_URL_LENGTH = 80;
19
+ const formatStatusUrl = (url) => {
20
+ if (url.length <= MAX_STATUS_URL_LENGTH) {
21
+ return url;
22
+ }
23
+ return `${url.slice(0, MAX_STATUS_URL_LENGTH - 3)}...`;
24
+ };
25
+ class ArchiveCommand extends core_1.Command {
26
+ async run() {
27
+ const { args, flags } = await this.parse(ArchiveCommand);
28
+ const targetUrl = args.url;
29
+ const outputFlag = flags.output ? flags.output.trim() : undefined;
30
+ const emit = flags.emit;
31
+ const type = flags.type;
32
+ const overwrite = flags.overwrite === true;
33
+ const triggerActions = (flags.action ?? []);
34
+ const runtime = flags.runtime;
35
+ const timeoutMs = typeof flags.timeout === "number" ? flags.timeout : undefined;
36
+ const maxDurationMs = typeof flags.maxDuration === "number" ? flags.maxDuration : undefined;
37
+ const headers = {
38
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
39
+ accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
40
+ "accept-language": "en-US,en;q=0.9",
41
+ referer: targetUrl
42
+ };
43
+ const pagepocket = lib_1.PagePocket.fromURL(targetUrl);
44
+ if (type === "single-file" && emit === "zip") {
45
+ throw new Error("--type single-file is not compatible with --emit zip (single-file is a directory output)");
46
+ }
47
+ const result = await (0, with_spinner_1.withSpinner)(async (spinner) => {
48
+ {
49
+ const writeDownOptions = {
50
+ type: emit,
51
+ outputPath: outputFlag ?? process.cwd(),
52
+ overwrite
53
+ };
54
+ const captureUnit = runtime === "puppeteer"
55
+ ? new capture_http_puppeteer_unit_1.CaptureHttpPuppeteerUnit({ triggerActions })
56
+ : runtime === "lighterceptor"
57
+ ? new capture_http_lighterceptor_unit_1.CaptureHttpLighterceptorUnit({ headers, triggerActions })
58
+ : new capture_http_cdp_unit_1.CaptureHttpCdpUnit({ triggerActions });
59
+ const units = [
60
+ new network_observer_unit_1.NetworkObserverUnit({
61
+ onRequest: (event) => {
62
+ spinner.text = `Freezing page (${formatStatusUrl(event.url)})`;
63
+ }
64
+ }),
65
+ captureUnit,
66
+ new build_snapshot_unit_1.BuildSnapshotUnit()
67
+ ];
68
+ if (type === "single-file") {
69
+ units.push(new single_file_unit_1.SingleFileUnit());
70
+ }
71
+ units.push(new write_down_unit_1.WriteDownUnit(writeDownOptions));
72
+ const entryTarget = runtime === "cdp"
73
+ ? (() => {
74
+ const tabId = flags.tabId;
75
+ if (typeof tabId !== "number") {
76
+ throw new Error("--tabId is required when --runtime cdp");
77
+ }
78
+ return lib_1.PagePocket.fromCDPTab(tabId);
79
+ })()
80
+ : pagepocket;
81
+ const result = await entryTarget.capture({
82
+ timeoutMs,
83
+ maxDurationMs,
84
+ blacklist: [...lib_1.ga, ...lib_1.ns],
85
+ units,
86
+ plugins: await (0, load_configured_plugins_1.loadConfiguredPlugins)().catch(() => [])
87
+ });
88
+ return result;
89
+ }
90
+ }, "Freezing page");
91
+ this.log(chalk_1.default.green("All done! Snapshot created."));
92
+ if (result.kind === "zip") {
93
+ this.log(`Snapshot saved to ${chalk_1.default.cyan(result.zip.outputPath)}`);
94
+ }
95
+ else if (result.kind === "raw") {
96
+ this.log(`Snapshot saved to ${chalk_1.default.cyan(result.outputDir)}`);
97
+ }
98
+ process.exit();
99
+ }
100
+ }
101
+ ArchiveCommand.description = "Archive a web page as an offline snapshot.";
102
+ ArchiveCommand.args = {
103
+ url: core_1.Args.string({
104
+ description: "URL to archive",
105
+ required: true
106
+ })
107
+ };
108
+ ArchiveCommand.flags = {
109
+ help: core_1.Flags.help({
110
+ char: "h"
111
+ }),
112
+ output: core_1.Flags.string({
113
+ char: "o",
114
+ description: "Output directory"
115
+ }),
116
+ emit: core_1.Flags.string({
117
+ description: "Output format",
118
+ options: ["raw", "zip"],
119
+ default: "raw"
120
+ }),
121
+ type: core_1.Flags.string({
122
+ description: "Snapshot type",
123
+ options: ["directory", "single-file"],
124
+ default: "directory"
125
+ }),
126
+ overwrite: core_1.Flags.boolean({
127
+ description: "Overwrite existing output directory instead of suffixing",
128
+ default: false
129
+ }),
130
+ action: core_1.Flags.string({
131
+ char: "a",
132
+ description: "Trigger action to run during/after capture (repeatable). Use -a multiple times to run multiple actions.",
133
+ options: Object.values({ HOVER: "HOVER", SCROLL_TO_END: "SCROLL_TO_END" }),
134
+ multiple: true
135
+ }),
136
+ runtime: core_1.Flags.string({
137
+ char: "r",
138
+ description: "Interceptor runtime to use",
139
+ options: ["lighterceptor", "puppeteer", "cdp"],
140
+ default: "puppeteer"
141
+ }),
142
+ tabId: core_1.Flags.integer({
143
+ description: "CDP tabId (required when --runtime cdp)"
144
+ }),
145
+ timeout: core_1.Flags.integer({
146
+ char: "t",
147
+ description: "Network idle duration in milliseconds before capture stops",
148
+ default: 5000
149
+ }),
150
+ maxDuration: core_1.Flags.integer({
151
+ description: "Hard max capture duration in milliseconds",
152
+ default: 60000
153
+ })
154
+ };
155
+ exports.default = ArchiveCommand;
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const core_1 = require("@oclif/core");
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const config_service_1 = require("../../services/config-service");
9
+ const plugin_installer_1 = require("../../services/plugin-installer");
10
+ const plugin_store_1 = require("../../services/plugin-store");
11
+ const parseDynamicOptions = (argv) => {
12
+ const tokens = argv.filter(Boolean);
13
+ const pairs = tokens
14
+ .map((t, i) => ({ t, next: tokens[i + 1] }))
15
+ .filter(({ t }) => t === "--option" || t.startsWith("--option-"));
16
+ const object = pairs
17
+ .filter(({ t }) => t.startsWith("--option-"))
18
+ .reduce((acc, { t, next }) => {
19
+ const key = t.slice("--option-".length);
20
+ if (!key) {
21
+ throw new Error("--option-* key is empty");
22
+ }
23
+ if (typeof next === "undefined" || next.startsWith("-")) {
24
+ throw new Error(`${t} expects a value`);
25
+ }
26
+ return { ...acc, [key]: next };
27
+ }, {});
28
+ const arrayValues = pairs
29
+ .filter(({ t }) => t === "--option")
30
+ .map(({ next }) => {
31
+ if (typeof next === "undefined" || next.startsWith("-")) {
32
+ throw new Error("--option expects a value");
33
+ }
34
+ return next;
35
+ });
36
+ const hasObject = Object.keys(object).length > 0;
37
+ const hasArray = arrayValues.length > 0;
38
+ if (hasObject && hasArray) {
39
+ throw new Error("Cannot mix --option with --option-* flags");
40
+ }
41
+ if (hasObject) {
42
+ return { kind: "object", value: object };
43
+ }
44
+ if (hasArray) {
45
+ return { kind: "array", value: arrayValues };
46
+ }
47
+ return { kind: "none" };
48
+ };
49
+ class PluginAddCommand extends core_1.Command {
50
+ async run() {
51
+ const [name, ...rest] = this.argv;
52
+ if (!name) {
53
+ throw new Error("plugin add: missing <plugin-name>");
54
+ }
55
+ if (name === "--help" || name === "-h") {
56
+ this.log("Usage: pp plugin add <plugin-name> [--option-* <value> ...] [--option <value> ...]");
57
+ return;
58
+ }
59
+ const configService = new config_service_1.ConfigService();
60
+ const store = new plugin_store_1.PluginStore(configService);
61
+ configService.ensureConfigFileExists();
62
+ const config = configService.readConfigOrDefault();
63
+ if (store.hasPlugin(config, name)) {
64
+ this.log(chalk_1.default.gray(`Plugin already exists in config: ${name}`));
65
+ return;
66
+ }
67
+ const parsed = parseDynamicOptions(rest);
68
+ const entry = parsed.kind === "none"
69
+ ? name
70
+ : parsed.kind === "array"
71
+ ? { name, options: parsed.value }
72
+ : { name, options: parsed.value };
73
+ (0, plugin_installer_1.installPluginPackage)(store, { packageName: name });
74
+ const mod = await store.importPluginModule(name);
75
+ if (typeof mod.default !== "function") {
76
+ throw new Error(`Plugin ${name} must have a default export (class).`);
77
+ }
78
+ const nextConfig = store.addPluginToConfig(config, entry);
79
+ configService.writeConfig(nextConfig);
80
+ await store.runOptionalSetup(name);
81
+ this.log(chalk_1.default.green(`Added plugin: ${name}`));
82
+ }
83
+ }
84
+ PluginAddCommand.description = "Install a plugin package, add it to config, and run its optional setup().";
85
+ exports.default = PluginAddCommand;
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const core_1 = require("@oclif/core");
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const config_service_1 = require("../../services/config-service");
9
+ const plugin_store_1 = require("../../services/plugin-store");
10
+ class PluginLsCommand extends core_1.Command {
11
+ async run() {
12
+ const configService = new config_service_1.ConfigService();
13
+ const store = new plugin_store_1.PluginStore(configService);
14
+ configService.ensureConfigFileExists();
15
+ const config = configService.readConfigOrDefault();
16
+ const entries = config.plugins;
17
+ if (entries.length === 0) {
18
+ this.log(chalk_1.default.gray("No plugins configured."));
19
+ return;
20
+ }
21
+ entries
22
+ .map((entry) => {
23
+ const name = typeof entry === "string" ? entry : entry.name;
24
+ const meta = store.readInstalledPackageMeta(name);
25
+ if (!meta) {
26
+ return `${chalk_1.default.yellow(name)} ${chalk_1.default.gray("(not installed)")}`;
27
+ }
28
+ const desc = meta.description
29
+ ? chalk_1.default.gray(meta.description)
30
+ : chalk_1.default.gray("(no description)");
31
+ const version = meta.version ? chalk_1.default.cyan(`v${meta.version}`) : chalk_1.default.gray("(no version)");
32
+ return `${chalk_1.default.green(meta.name)} ${version} ${desc}`;
33
+ })
34
+ .forEach((line) => this.log(line));
35
+ }
36
+ }
37
+ PluginLsCommand.description = "List configured plugins.";
38
+ exports.default = PluginLsCommand;
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const core_1 = require("@oclif/core");
7
+ const chalk_1 = __importDefault(require("chalk"));
8
+ const config_service_1 = require("../../services/config-service");
9
+ const plugin_store_1 = require("../../services/plugin-store");
10
+ class PluginRemoveCommand extends core_1.Command {
11
+ async run() {
12
+ const { args } = await this.parse(PluginRemoveCommand);
13
+ const name = args.name;
14
+ const configService = new config_service_1.ConfigService();
15
+ const store = new plugin_store_1.PluginStore(configService);
16
+ configService.ensureConfigFileExists();
17
+ const config = configService.readConfigOrDefault();
18
+ const out = store.removePluginFromConfig(config, name);
19
+ if (!out.removed) {
20
+ this.log(chalk_1.default.gray(`Plugin not found in config: ${name}`));
21
+ return;
22
+ }
23
+ configService.writeConfig(out.config);
24
+ this.log(chalk_1.default.green(`Removed from config: ${name}`));
25
+ }
26
+ }
27
+ PluginRemoveCommand.description = "Remove a plugin from config (does not uninstall).";
28
+ PluginRemoveCommand.args = {
29
+ name: core_1.Args.string({
30
+ description: "npm package name",
31
+ required: true
32
+ })
33
+ };
34
+ exports.default = PluginRemoveCommand;
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const node_fs_1 = __importDefault(require("node:fs"));
7
+ const node_path_1 = __importDefault(require("node:path"));
8
+ const node_process_1 = __importDefault(require("node:process"));
9
+ const core_1 = require("@oclif/core");
10
+ const chalk_1 = __importDefault(require("chalk"));
11
+ const view_1 = require("../view");
12
+ class ViewCommand extends core_1.Command {
13
+ async run() {
14
+ const { args, flags } = await this.parse(ViewCommand);
15
+ const rootDir = node_path_1.default.resolve(node_process_1.default.cwd(), args.directory);
16
+ const indexPath = node_path_1.default.join(rootDir, "index.html");
17
+ const stat = await node_fs_1.default.promises.stat(rootDir).catch(() => null);
18
+ if (!stat || !stat.isDirectory()) {
19
+ throw new Error(`view: directory not found: ${rootDir}`);
20
+ }
21
+ const indexStat = await node_fs_1.default.promises.stat(indexPath).catch(() => null);
22
+ if (!indexStat || !indexStat.isFile()) {
23
+ throw new Error(`view: index.html not found under: ${rootDir}`);
24
+ }
25
+ const portFromEnv = node_process_1.default.env.PORT ? Number(node_process_1.default.env.PORT) : undefined;
26
+ const port = typeof flags.port === "number"
27
+ ? flags.port
28
+ : typeof portFromEnv === "number"
29
+ ? portFromEnv
30
+ : 4173;
31
+ if (!Number.isFinite(port) || port < 0 || port > 65535) {
32
+ throw new Error(`Invalid --port: ${String(flags.port ?? node_process_1.default.env.PORT)}`);
33
+ }
34
+ const server = await (0, view_1.createViewServer)({
35
+ rootDir,
36
+ host: flags.host,
37
+ port
38
+ });
39
+ this.log(`Serving ${chalk_1.default.cyan(rootDir)}`);
40
+ this.log(chalk_1.default.green(server.url));
41
+ }
42
+ }
43
+ ViewCommand.description = "Serve a directory via a local static server (SPA fallback to index.html).";
44
+ ViewCommand.args = {
45
+ directory: core_1.Args.string({
46
+ description: "Directory to serve (must contain index.html)",
47
+ required: true
48
+ })
49
+ };
50
+ ViewCommand.flags = {
51
+ help: core_1.Flags.help({
52
+ char: "h"
53
+ }),
54
+ host: core_1.Flags.string({
55
+ description: "Host to bind",
56
+ default: "127.0.0.1"
57
+ }),
58
+ port: core_1.Flags.integer({
59
+ description: "Port to listen on (0 for random)",
60
+ default: 4173
61
+ })
62
+ };
63
+ exports.default = ViewCommand;
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const core_1 = require("@oclif/core");
4
- (0, core_1.run)().catch((error) => {
4
+ const normalize_argv_1 = require("./utils/normalize-argv");
5
+ (0, core_1.run)((0, normalize_argv_1.normalizeArgv)(process.argv.slice(2))).catch((error) => {
5
6
  const message = error && typeof error.message === "string" ? error.message : String(error);
6
7
  console.error(message);
7
8
  const exitCode = error && typeof error.exitCode === "number" ? error.exitCode : 1;
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ConfigService = void 0;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_os_1 = __importDefault(require("node:os"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const env_paths_1 = __importDefault(require("env-paths"));
11
+ const defaultConfig = {
12
+ plugins: []
13
+ };
14
+ class ConfigService {
15
+ constructor(appName = "pagepocket") {
16
+ this.appName = appName;
17
+ }
18
+ getConfigDir() {
19
+ if (process.platform === "win32") {
20
+ const p = (0, env_paths_1.default)(this.appName, { suffix: "" });
21
+ return p.config;
22
+ }
23
+ const homeDir = node_os_1.default.homedir();
24
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME;
25
+ const base = xdgConfigHome && xdgConfigHome.trim() ? xdgConfigHome : node_path_1.default.join(homeDir, ".config");
26
+ return node_path_1.default.join(base, this.appName);
27
+ }
28
+ getConfigPath() {
29
+ return node_path_1.default.join(this.getConfigDir(), "config.json");
30
+ }
31
+ getPluginsInstallDir() {
32
+ return node_path_1.default.join(this.getConfigDir(), "plugins");
33
+ }
34
+ ensureConfigDirExists() {
35
+ this.ensureDirExists(this.getConfigDir());
36
+ }
37
+ ensureDirExists(dirPath) {
38
+ node_fs_1.default.mkdirSync(dirPath, { recursive: true });
39
+ }
40
+ writeJsonAtomic(filePath, value) {
41
+ const dir = node_path_1.default.dirname(filePath);
42
+ this.ensureDirExists(dir);
43
+ const tmpPath = `${filePath}.tmp`;
44
+ node_fs_1.default.writeFileSync(tmpPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
45
+ node_fs_1.default.renameSync(tmpPath, filePath);
46
+ }
47
+ isObject(value) {
48
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
49
+ }
50
+ normalizeConfig(value) {
51
+ if (!this.isObject(value)) {
52
+ return defaultConfig;
53
+ }
54
+ const pluginsRaw = value.plugins;
55
+ if (!Array.isArray(pluginsRaw)) {
56
+ return defaultConfig;
57
+ }
58
+ const plugins = pluginsRaw
59
+ .map((entry) => {
60
+ if (typeof entry === "string") {
61
+ return entry;
62
+ }
63
+ if (this.isObject(entry) && typeof entry.name === "string") {
64
+ const options = "options" in entry ? entry.options : undefined;
65
+ return {
66
+ name: entry.name,
67
+ ...(typeof options === "undefined" ? {} : { options })
68
+ };
69
+ }
70
+ return null;
71
+ })
72
+ .filter((x) => x !== null);
73
+ return { plugins };
74
+ }
75
+ readConfig() {
76
+ const configPath = this.getConfigPath();
77
+ const rawText = node_fs_1.default.readFileSync(configPath, "utf8");
78
+ const parsed = JSON.parse(rawText);
79
+ return this.normalizeConfig(parsed);
80
+ }
81
+ isConfigFilePresent() {
82
+ return node_fs_1.default.existsSync(this.getConfigPath());
83
+ }
84
+ readConfigOrDefault() {
85
+ const configPath = this.getConfigPath();
86
+ const text = node_fs_1.default.existsSync(configPath) ? node_fs_1.default.readFileSync(configPath, "utf8") : null;
87
+ if (!text) {
88
+ return defaultConfig;
89
+ }
90
+ try {
91
+ const parsed = JSON.parse(text);
92
+ return this.normalizeConfig(parsed);
93
+ }
94
+ catch {
95
+ return defaultConfig;
96
+ }
97
+ }
98
+ writeConfig(config) {
99
+ this.writeJsonAtomic(this.getConfigPath(), this.normalizeConfig(config));
100
+ }
101
+ ensureConfigFileExists() {
102
+ const configPath = this.getConfigPath();
103
+ if (node_fs_1.default.existsSync(configPath)) {
104
+ return this.readConfigOrDefault();
105
+ }
106
+ this.writeConfig(defaultConfig);
107
+ return defaultConfig;
108
+ }
109
+ }
110
+ exports.ConfigService = ConfigService;
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.loadConfiguredPlugins = void 0;
4
+ const config_service_1 = require("./config-service");
5
+ const plugin_store_1 = require("./plugin-store");
6
+ const loadConfiguredPlugins = async () => {
7
+ const configService = new config_service_1.ConfigService();
8
+ const store = new plugin_store_1.PluginStore(configService);
9
+ configService.ensureConfigFileExists();
10
+ const config = configService.readConfigOrDefault();
11
+ const specs = config.plugins.map(plugin_store_1.normalizePluginConfigEntry);
12
+ const loaded = await Promise.all(specs.map(async (spec) => {
13
+ try {
14
+ const p = await store.instantiatePluginFromSpec(spec);
15
+ if (!p || typeof p.setup !== "function") {
16
+ console.error(`Skipping plugin ${spec.name}: default export is not a PagePocket plugin instance.`);
17
+ return null;
18
+ }
19
+ return p;
20
+ }
21
+ catch (e) {
22
+ const msg = e && typeof e.message === "string"
23
+ ? e.message
24
+ : String(e);
25
+ console.error(`Failed to load plugin ${spec.name}: ${msg}`);
26
+ return null;
27
+ }
28
+ }));
29
+ return loaded.filter((x) => x !== null);
30
+ };
31
+ exports.loadConfiguredPlugins = loadConfiguredPlugins;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.installPluginPackage = void 0;
4
+ const node_child_process_1 = require("node:child_process");
5
+ const installPluginPackage = (store, input) => {
6
+ store.ensurePluginPackageJson();
7
+ const installDir = store.getInstallDir();
8
+ const tryRun = (cmd, args) => {
9
+ const r = (0, node_child_process_1.spawnSync)(cmd, args, {
10
+ cwd: installDir,
11
+ stdio: "inherit",
12
+ shell: false
13
+ });
14
+ if (r.error) {
15
+ return { ok: false, error: r.error };
16
+ }
17
+ if (typeof r.status === "number" && r.status !== 0) {
18
+ return { ok: false, error: new Error(`${cmd} exited with code ${r.status}`) };
19
+ }
20
+ return { ok: true };
21
+ };
22
+ const pnpm = tryRun("pnpm", ["add", "--silent", input.packageName]);
23
+ if (pnpm.ok) {
24
+ return;
25
+ }
26
+ const npm = tryRun("npm", ["install", "--silent", input.packageName]);
27
+ if (npm.ok) {
28
+ return;
29
+ }
30
+ throw new Error(`Failed to install ${input.packageName}. Ensure pnpm or npm is available.`);
31
+ };
32
+ exports.installPluginPackage = installPluginPackage;
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.PluginStore = exports.getPluginNameFromSpec = exports.normalizePluginConfigEntry = void 0;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const node_module_1 = require("node:module");
10
+ const node_url_1 = require("node:url");
11
+ const normalizePluginConfigEntry = (entry) => {
12
+ if (typeof entry === "string") {
13
+ return { name: entry };
14
+ }
15
+ return {
16
+ name: entry.name,
17
+ ...(typeof entry.options === "undefined" ? {} : { options: entry.options })
18
+ };
19
+ };
20
+ exports.normalizePluginConfigEntry = normalizePluginConfigEntry;
21
+ const getPluginNameFromSpec = (spec) => spec.name;
22
+ exports.getPluginNameFromSpec = getPluginNameFromSpec;
23
+ class PluginStore {
24
+ constructor(configService) {
25
+ this.configService = configService;
26
+ }
27
+ getInstallDir() {
28
+ return this.configService.getPluginsInstallDir();
29
+ }
30
+ ensureInstallDir() {
31
+ node_fs_1.default.mkdirSync(this.getInstallDir(), { recursive: true });
32
+ }
33
+ ensurePluginPackageJson() {
34
+ this.ensureInstallDir();
35
+ const pkgJsonPath = node_path_1.default.join(this.getInstallDir(), "package.json");
36
+ if (node_fs_1.default.existsSync(pkgJsonPath)) {
37
+ return pkgJsonPath;
38
+ }
39
+ const pkgJson = {
40
+ name: "pagepocket-user-plugins",
41
+ private: true,
42
+ version: "0.0.0"
43
+ };
44
+ node_fs_1.default.writeFileSync(pkgJsonPath, `${JSON.stringify(pkgJson, null, 2)}\n`, "utf8");
45
+ return pkgJsonPath;
46
+ }
47
+ createPluginsRequire() {
48
+ const pkgJsonPath = this.ensurePluginPackageJson();
49
+ return (0, node_module_1.createRequire)(pkgJsonPath);
50
+ }
51
+ resolveInstalledPackageJsonPath(pluginName) {
52
+ const req = this.createPluginsRequire();
53
+ return req.resolve(`${pluginName}/package.json`);
54
+ }
55
+ readInstalledPackageMeta(pluginName) {
56
+ try {
57
+ const pkgJsonPath = this.resolveInstalledPackageJsonPath(pluginName);
58
+ const text = node_fs_1.default.readFileSync(pkgJsonPath, "utf8");
59
+ const json = JSON.parse(text);
60
+ if (!json || typeof json !== "object") {
61
+ return { name: pluginName };
62
+ }
63
+ const o = json;
64
+ return {
65
+ name: typeof o.name === "string" ? o.name : pluginName,
66
+ description: typeof o.description === "string" ? o.description : undefined,
67
+ version: typeof o.version === "string" ? o.version : undefined
68
+ };
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ async importPluginModule(pluginName) {
75
+ const req = this.createPluginsRequire();
76
+ const resolved = req.resolve(pluginName);
77
+ const dynamicImport = new Function("specifier", "return import(specifier)");
78
+ const mod = await dynamicImport((0, node_url_1.pathToFileURL)(resolved).href);
79
+ return (mod ?? {});
80
+ }
81
+ async instantiatePluginFromSpec(spec) {
82
+ const mod = await this.importPluginModule(spec.name);
83
+ const defaultExport = mod.default;
84
+ if (typeof defaultExport !== "function") {
85
+ throw new Error(`Plugin ${spec.name} has no default export (class/function).`);
86
+ }
87
+ const ctor = defaultExport;
88
+ if (typeof spec.options === "undefined") {
89
+ return new ctor();
90
+ }
91
+ if (Array.isArray(spec.options)) {
92
+ return new ctor(...spec.options);
93
+ }
94
+ return new ctor(spec.options);
95
+ }
96
+ async runOptionalSetup(pluginName) {
97
+ const mod = await this.importPluginModule(pluginName);
98
+ const setup = mod.setup;
99
+ if (typeof setup === "function") {
100
+ await setup();
101
+ }
102
+ }
103
+ readConfig() {
104
+ return this.configService.readConfigOrDefault();
105
+ }
106
+ writeConfig(next) {
107
+ this.configService.writeConfig(next);
108
+ }
109
+ hasPlugin(config, pluginName) {
110
+ return config.plugins.some((e) => (0, exports.getPluginNameFromSpec)((0, exports.normalizePluginConfigEntry)(e)) === pluginName);
111
+ }
112
+ addPluginToConfig(config, entry) {
113
+ const spec = (0, exports.normalizePluginConfigEntry)(entry);
114
+ if (this.hasPlugin(config, spec.name)) {
115
+ return config;
116
+ }
117
+ return { ...config, plugins: [...config.plugins, entry] };
118
+ }
119
+ removePluginFromConfig(config, pluginName) {
120
+ const before = config.plugins;
121
+ const after = before.filter((e) => (0, exports.getPluginNameFromSpec)((0, exports.normalizePluginConfigEntry)(e)) !== pluginName);
122
+ return {
123
+ config: { ...config, plugins: after },
124
+ removed: after.length !== before.length
125
+ };
126
+ }
127
+ }
128
+ exports.PluginStore = PluginStore;
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NetworkObserverUnit = void 0;
4
+ const contracts_1 = require("@pagepocket/contracts");
5
+ const lib_1 = require("@pagepocket/lib");
6
+ /**
7
+ * Observes the network@1 channel and forwards events to user-provided callbacks.
8
+ *
9
+ * This unit is intentionally UI-agnostic (no CLI/spinner coupling).
10
+ */
11
+ class NetworkObserverUnit extends lib_1.Unit {
12
+ constructor(config = {}) {
13
+ super();
14
+ this.id = "networkObserver";
15
+ this.kind = "observe.network";
16
+ this.config = config;
17
+ }
18
+ async run(_ctx, rt) {
19
+ const config = this.config;
20
+ const task = (async () => {
21
+ for await (const event of rt.subscribe(contracts_1.NETWORK)) {
22
+ await config.onEvent?.(event);
23
+ if (event.type === "request") {
24
+ await config.onRequest?.(event);
25
+ }
26
+ else if (event.type === "response") {
27
+ await config.onResponse?.(event);
28
+ }
29
+ else if (event.type === "failed") {
30
+ await config.onFailed?.(event);
31
+ }
32
+ }
33
+ })();
34
+ rt.defer(task);
35
+ }
36
+ }
37
+ exports.NetworkObserverUnit = NetworkObserverUnit;
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeArgv = exports.isUrlLike = void 0;
4
+ const isUrlLike = (value) => {
5
+ try {
6
+ const url = new URL(value);
7
+ return url.protocol === "http:" || url.protocol === "https:";
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ };
13
+ exports.isUrlLike = isUrlLike;
14
+ const normalizeArgv = (argv) => {
15
+ // pnpm forwards args to scripts by inserting a literal `--`.
16
+ // oclif does not treat that as a flag; strip it if present.
17
+ if (argv[0] === "--") {
18
+ argv = argv.slice(1);
19
+ }
20
+ if (argv.length === 0) {
21
+ return argv;
22
+ }
23
+ const first = argv[0];
24
+ if (first === "plugin") {
25
+ const sub = argv[1];
26
+ if (sub === "ls" || sub === "add" || sub === "remove") {
27
+ return [`plugin:${sub}`, ...argv.slice(2)];
28
+ }
29
+ }
30
+ // If the first token is a URL, treat it as implicit `archive`.
31
+ if (first && (0, exports.isUrlLike)(first)) {
32
+ return ["archive", ...argv];
33
+ }
34
+ return argv;
35
+ };
36
+ exports.normalizeArgv = normalizeArgv;
package/dist/view.js ADDED
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createViewServer = void 0;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const koa_1 = __importDefault(require("koa"));
10
+ const koa_send_1 = __importDefault(require("koa-send"));
11
+ const koa_static_1 = __importDefault(require("koa-static"));
12
+ const createViewServer = async (args) => {
13
+ const indexPath = node_path_1.default.join(args.rootDir, "index.html");
14
+ const stat = await node_fs_1.default.promises.stat(args.rootDir).catch(() => null);
15
+ if (!stat || !stat.isDirectory()) {
16
+ throw new Error(`view: directory not found: ${args.rootDir}`);
17
+ }
18
+ const indexStat = await node_fs_1.default.promises.stat(indexPath).catch(() => null);
19
+ if (!indexStat || !indexStat.isFile()) {
20
+ throw new Error(`view: index.html not found under: ${args.rootDir}`);
21
+ }
22
+ const app = new koa_1.default();
23
+ // Avoid Koa's generator-function detection (and its dependency chain) by
24
+ // not calling `app.use()`.
25
+ //
26
+ // In some ESM/CJS interop environments, koa's transitive dependency
27
+ // `is-generator-function` can break ("getGeneratorFunction is not a function")
28
+ // when it tries to require `generator-function`. We don't need generator
29
+ // middleware support here, so bypass it entirely.
30
+ const staticMw = (0, koa_static_1.default)(args.rootDir, { index: false });
31
+ app.middleware.push(staticMw);
32
+ app.middleware.push(async (ctx) => {
33
+ // SPA fallback (anything not matched by static middleware)
34
+ ctx.status = 200;
35
+ await (0, koa_send_1.default)(ctx, "index.html", {
36
+ root: args.rootDir
37
+ });
38
+ });
39
+ const server = await new Promise((resolve, reject) => {
40
+ const s = app.listen(args.port, args.host, () => resolve(s));
41
+ s.on("error", reject);
42
+ });
43
+ const address = server.address();
44
+ const port = typeof address === "object" && address ? address.port : args.port;
45
+ const host = args.host;
46
+ const url = `http://${host}:${port}`;
47
+ return {
48
+ url,
49
+ close: async () => {
50
+ await new Promise((resolve, reject) => {
51
+ server.close((err) => (err ? reject(err) : resolve()));
52
+ });
53
+ }
54
+ };
55
+ };
56
+ exports.createViewServer = createViewServer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagepocket/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "CLI for capturing offline snapshots of web pages.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -15,27 +15,39 @@
15
15
  "dependencies": {
16
16
  "@oclif/core": "^4.0.9",
17
17
  "chalk": "^4.1.2",
18
+ "koa": "^2.16.2",
19
+ "koa-send": "^5.0.1",
20
+ "koa-static": "^5.0.0",
18
21
  "ora": "^9.0.0",
19
- "@pagepocket/lib": "0.7.0",
20
- "@pagepocket/interceptor": "0.7.0",
21
- "@pagepocket/lighterceptor-adapter": "0.7.0",
22
- "@pagepocket/puppeteer-adapter": "0.7.0"
22
+ "env-paths": "^2.2.1",
23
+ "@pagepocket/lib": "0.8.0",
24
+ "@pagepocket/capture-http-cdp-unit": "0.8.0",
25
+ "@pagepocket/capture-http-puppeteer-unit": "0.8.0",
26
+ "@pagepocket/build-snapshot-unit": "0.8.0",
27
+ "@pagepocket/capture-http-lighterceptor-unit": "0.8.0",
28
+ "@pagepocket/plugin-yt-dlp": "0.8.0",
29
+ "@pagepocket/single-file-unit": "0.8.0",
30
+ "@pagepocket/write-down-unit": "0.8.0",
31
+ "@pagepocket/contracts": "0.8.0"
23
32
  },
24
33
  "devDependencies": {
25
34
  "@types/node": "^20.11.30",
35
+ "@types/koa": "^2.15.0",
36
+ "@types/koa-send": "^4.1.5",
37
+ "@types/koa-static": "^4.0.4",
26
38
  "tsx": "^4.19.3",
27
39
  "typescript": "^5.4.5"
28
40
  },
29
41
  "oclif": {
30
42
  "bin": "pp",
31
43
  "commands": {
32
- "strategy": "single",
33
- "target": "dist/cli.js"
44
+ "strategy": "pattern",
45
+ "target": "dist/commands"
34
46
  }
35
47
  },
36
48
  "scripts": {
37
- "build": "tsc",
49
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
38
50
  "start": "node dist/index.js",
39
- "test": "tsx --test specs/*.spec.ts"
51
+ "test": "pnpm build && tsx --test specs/*.spec.ts"
40
52
  }
41
53
  }
package/dist/cli.js DELETED
@@ -1,125 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- const core_1 = require("@oclif/core");
7
- const interceptor_1 = require("@pagepocket/interceptor");
8
- const lib_1 = require("@pagepocket/lib");
9
- const lighterceptor_adapter_1 = require("@pagepocket/lighterceptor-adapter");
10
- const puppeteer_adapter_1 = require("@pagepocket/puppeteer-adapter");
11
- const chalk_1 = __importDefault(require("chalk"));
12
- const prepare_output_1 = require("./stages/prepare-output");
13
- const write_snapshot_1 = require("./stages/write-snapshot");
14
- const with_spinner_1 = require("./utils/with-spinner");
15
- const MAX_STATUS_URL_LENGTH = 80;
16
- const formatStatusUrl = (url) => {
17
- if (url.length <= MAX_STATUS_URL_LENGTH) {
18
- return url;
19
- }
20
- return `${url.slice(0, MAX_STATUS_URL_LENGTH - 3)}...`;
21
- };
22
- class PagepocketCommand extends core_1.Command {
23
- async run() {
24
- const { args, flags } = await this.parse(PagepocketCommand);
25
- const targetUrl = args.url;
26
- const outputFlag = flags.output ? flags.output.trim() : undefined;
27
- const overwrite = flags.overwrite === true;
28
- const triggerActions = (flags.action ?? []);
29
- const runtime = flags.runtime;
30
- const timeoutMs = typeof flags.timeout === "number" ? flags.timeout : undefined;
31
- const maxDurationMs = typeof flags.maxDuration === "number" ? flags.maxDuration : undefined;
32
- const headers = {
33
- "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
34
- accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
35
- "accept-language": "en-US,en;q=0.9",
36
- referer: targetUrl
37
- };
38
- const interceptor = runtime === "puppeteer"
39
- ? new puppeteer_adapter_1.PuppeteerAdapter({
40
- // gotoOptions: { waitUntil: "networkidle2" },
41
- triggerActions
42
- })
43
- : new lighterceptor_adapter_1.LighterceptorAdapter({ headers, triggerActions });
44
- const pagepocket = lib_1.PagePocket.fromURL(targetUrl);
45
- const snapshot = await (0, with_spinner_1.withSpinner)(async (spinner) => {
46
- const eventsTask = (async () => {
47
- for await (const event of pagepocket.interceptedRequestEvents()) {
48
- if (event.type === "request") {
49
- spinner.text = `Freezing page (${formatStatusUrl(event.url)})`;
50
- }
51
- }
52
- })();
53
- try {
54
- return await pagepocket.capture({
55
- interceptor,
56
- timeoutMs,
57
- maxDurationMs,
58
- blacklist: [...lib_1.ga, ...lib_1.sw_iframe, ...lib_1.ns]
59
- });
60
- }
61
- finally {
62
- try {
63
- await eventsTask;
64
- }
65
- catch {
66
- // Ignore event stream errors during shutdown.
67
- }
68
- }
69
- }, "Freezing page");
70
- const { outputDir } = await (0, with_spinner_1.withSpinner)((_spinner) => (0, prepare_output_1.prepareOutputDir)(snapshot.title ?? "snapshot", outputFlag), "Preparing output directory");
71
- const writeResult = await (0, with_spinner_1.withSpinner)(async (_spinner) => {
72
- return (0, write_snapshot_1.writeSnapshotFiles)({
73
- outputDir,
74
- snapshot,
75
- overwrite
76
- });
77
- }, "Writing snapshot files");
78
- const resolvedOutputDir = writeResult?.outputDir ?? outputDir;
79
- this.log(chalk_1.default.green("All done! Snapshot created."));
80
- this.log(`Snapshot saved to ${chalk_1.default.cyan(resolvedOutputDir)}`);
81
- process.exit();
82
- }
83
- }
84
- PagepocketCommand.description = "Save a snapshot of a web page.";
85
- PagepocketCommand.args = {
86
- url: core_1.Args.string({
87
- description: "URL to snapshot",
88
- required: true
89
- })
90
- };
91
- PagepocketCommand.flags = {
92
- help: core_1.Flags.help({
93
- char: "h"
94
- }),
95
- output: core_1.Flags.string({
96
- char: "o",
97
- description: "Output path for the snapshot HTML file"
98
- }),
99
- overwrite: core_1.Flags.boolean({
100
- description: "Overwrite existing output directory instead of suffixing",
101
- default: false
102
- }),
103
- action: core_1.Flags.string({
104
- char: "a",
105
- description: "Trigger action to run during/after capture (repeatable). Use -a multiple times to run multiple actions.",
106
- options: Object.values(interceptor_1.TriggerActionValues),
107
- multiple: true
108
- }),
109
- runtime: core_1.Flags.string({
110
- char: "r",
111
- description: "Interceptor runtime to use",
112
- options: ["lighterceptor", "puppeteer"],
113
- default: "lighterceptor"
114
- }),
115
- timeout: core_1.Flags.integer({
116
- char: "t",
117
- description: "Network idle duration in milliseconds before capture stops",
118
- default: 5000
119
- }),
120
- maxDuration: core_1.Flags.integer({
121
- description: "Hard max capture duration in milliseconds",
122
- default: 60000
123
- })
124
- };
125
- exports.default = PagepocketCommand;
@@ -1,9 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.writeSnapshotFiles = void 0;
4
- const writeSnapshotFiles = async (input) => {
5
- return input.snapshot.toDirectory(input.outputDir, {
6
- overwrite: input.overwrite
7
- });
8
- };
9
- exports.writeSnapshotFiles = writeSnapshotFiles;