@pagepocket/cli 0.12.0 → 0.14.5

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.
@@ -1,139 +1,12 @@
1
- import { createRequire } from "node:module";
2
- import { pathToFileURL } from "node:url";
3
1
  import { Args, Command, Flags } from "@oclif/core";
4
2
  import { ga, ns, PagePocket } from "@pagepocket/lib";
5
3
  import chalk from "chalk";
4
+ import ora from "ora";
6
5
  import { ConfigService } from "../services/config-service.js";
7
6
  import { loadConfiguredPlugins } from "../services/load-configured-plugins.js";
8
- import { readBuiltinStrategy, listBuiltinStrategyNames } from "../services/strategy/builtin-strategy-registry.js";
9
- import { normalizeStrategyUnits } from "../services/strategy/strategy-normalize.js";
10
7
  import { StrategyService } from "../services/strategy/strategy-service.js";
11
- import { UnitStore } from "../services/units/unit-store.js";
12
- import { isUnitLike, resolveUnitConstructor } from "../services/units/unit-validate.js";
13
- import { parsePinnedSpec } from "../services/user-packages/parse-pinned-spec.js";
14
- import { uniq } from "../utils/array.js";
15
- import { withSpinner } from "../utils/with-spinner.js";
16
- const buildMissingStrategyError = (input) => {
17
- const available = uniq([...input.installedNames, ...listBuiltinStrategyNames()])
18
- .filter((name) => name.trim().length > 0)
19
- .sort((left, right) => left.localeCompare(right));
20
- const suffix = available.length > 0
21
- ? ` Available strategies: ${available.join(", ")}`
22
- : " No strategies found.";
23
- return new Error(`Strategy not found: ${input.strategyName}.${suffix}`);
24
- };
25
- const resolveStrategy = (input) => {
26
- const installedNames = input.strategyService.listInstalledStrategyNames();
27
- if (installedNames.includes(input.strategyName)) {
28
- return {
29
- name: input.strategyName,
30
- strategyFile: input.strategyService.readStrategy(input.strategyName),
31
- source: "installed"
32
- };
33
- }
34
- const builtin = readBuiltinStrategy(input.strategyName);
35
- if (builtin) {
36
- return {
37
- name: input.strategyName,
38
- strategyFile: builtin,
39
- source: "builtin"
40
- };
41
- }
42
- throw buildMissingStrategyError({
43
- strategyName: input.strategyName,
44
- installedNames
45
- });
46
- };
47
- const assertNoDuplicateUnitIds = (input) => {
48
- const seen = new Set();
49
- const duplicateUnit = input.units.find((unit) => {
50
- if (seen.has(unit.id)) {
51
- return true;
52
- }
53
- seen.add(unit.id);
54
- return false;
55
- });
56
- if (duplicateUnit) {
57
- throw new Error(`Duplicate unit id detected in strategy ${input.strategyName}: ${duplicateUnit.id}`);
58
- }
59
- };
60
- const loadInstalledStrategyUnits = async (input) => {
61
- const strategyService = new StrategyService(input.configService);
62
- const unitStore = new UnitStore(input.configService);
63
- strategyService.ensureConfigFileExists();
64
- const strategyFile = strategyService.readStrategy(input.strategyName);
65
- const normalized = normalizeStrategyUnits(strategyFile);
66
- const installed = unitStore.readInstalledDependencyVersions();
67
- const drift = normalized.flatMap((unit) => {
68
- const pinned = parsePinnedSpec(unit.ref);
69
- const installedVersion = installed[pinned.name];
70
- if (!installedVersion || installedVersion !== pinned.version) {
71
- return [
72
- {
73
- name: pinned.name,
74
- pinned: pinned.version,
75
- installed: installedVersion
76
- }
77
- ];
78
- }
79
- return [];
80
- });
81
- if (drift.length > 0) {
82
- const details = drift
83
- .map((driftItem) => {
84
- const installedText = typeof driftItem.installed === "string" ? driftItem.installed : "(not installed)";
85
- return `${driftItem.name}: pinned ${driftItem.pinned}, installed ${installedText}`;
86
- })
87
- .join("; ");
88
- throw new Error(`Strategy drift detected (${input.strategyName}). ${details}. ` +
89
- `Fix: pp strategy update ${input.strategyName} OR pp strategy pin ${input.strategyName}`);
90
- }
91
- const units = await Promise.all(normalized.map(async (unit) => unitStore.instantiateFromRef(unit.ref, unit.args)));
92
- assertNoDuplicateUnitIds({
93
- strategyName: input.strategyName,
94
- units
95
- });
96
- return units;
97
- };
98
- const instantiateBuiltinUnit = async (input) => {
99
- const req = createRequire(import.meta.url);
100
- const pinned = parsePinnedSpec(input.ref);
101
- const resolved = req.resolve(pinned.name);
102
- const mod = (await import(pathToFileURL(resolved).href));
103
- const ctor = resolveUnitConstructor(mod);
104
- if (!ctor) {
105
- throw new Error(`Unit ${pinned.name} does not export a Unit constructor.`);
106
- }
107
- const instance = new ctor(...input.args);
108
- if (!isUnitLike(instance)) {
109
- throw new Error(`Unit ${pinned.name} exported constructor did not produce a Unit.`);
110
- }
111
- return instance;
112
- };
113
- const loadBuiltinStrategyUnits = async (input) => {
114
- const normalized = normalizeStrategyUnits(input.strategyFile);
115
- const units = await Promise.all(normalized.map(async (unit) => instantiateBuiltinUnit({
116
- ref: unit.ref,
117
- args: unit.args
118
- })));
119
- assertNoDuplicateUnitIds({
120
- strategyName: input.strategyName,
121
- units
122
- });
123
- return units;
124
- };
125
- const loadStrategyUnits = async (input) => {
126
- if (input.strategy.source === "installed") {
127
- return loadInstalledStrategyUnits({
128
- strategyName: input.strategy.name,
129
- configService: input.configService
130
- });
131
- }
132
- return loadBuiltinStrategyUnits({
133
- strategyName: input.strategy.name,
134
- strategyFile: input.strategy.strategyFile
135
- });
136
- };
8
+ import { loadStrategyUnits, resolveStrategy } from "../utils/archive-strategy.js";
9
+ import { parseEnvVars, resolveStrategyVars } from "../utils/strategy-vars.js";
137
10
  export default class ArchiveCommand extends Command {
138
11
  static description = "Archive a web page as an offline snapshot.";
139
12
  static args = {
@@ -146,57 +19,66 @@ export default class ArchiveCommand extends Command {
146
19
  help: Flags.help({
147
20
  char: "h"
148
21
  }),
149
- timeout: Flags.integer({
150
- char: "t",
151
- description: "Network idle duration in milliseconds before capture stops",
152
- required: false
153
- }),
154
- maxDuration: Flags.integer({
155
- description: "Hard max capture duration in milliseconds",
156
- required: false
157
- }),
158
22
  strategy: Flags.string({
159
23
  description: "Run an installed or built-in strategy (unit pipeline) by name"
24
+ }),
25
+ env: Flags.string({
26
+ char: "e",
27
+ description: "Strategy variable in key:value format (e.g. -e \"timeout:3000\")",
28
+ multiple: true
160
29
  })
161
30
  };
162
31
  async run() {
163
32
  const { args, flags } = await this.parse(ArchiveCommand);
164
33
  const targetUrl = args.url;
165
- const timeoutMs = typeof flags.timeout === "number" ? flags.timeout : undefined;
166
- const maxDurationMs = typeof flags.maxDuration === "number" ? flags.maxDuration : undefined;
167
34
  const strategyName = typeof flags.strategy === "string" ? flags.strategy.trim() : undefined;
35
+ const configService = new ConfigService();
36
+ const name = strategyName && strategyName.length > 0 ? strategyName : "default";
37
+ const strategyService = new StrategyService(configService);
38
+ strategyService.ensureConfigFileExists();
39
+ const strategy = resolveStrategy({ strategyName: name, strategyService });
40
+ const envVars = parseEnvVars(flags.env ?? []);
41
+ const resolvedStrategyFile = resolveStrategyVars(strategy.strategyFile, envVars);
42
+ const resolvedStrategy = { ...strategy, strategyFile: resolvedStrategyFile };
43
+ const units = await loadStrategyUnits({ strategy: resolvedStrategy, configService });
44
+ const captureOptions = resolvedStrategy.strategyFile.pipeline.captureOptions;
168
45
  const pagepocket = PagePocket.fromURL(targetUrl);
169
- const result = await withSpinner(async (spinner) => {
170
- {
171
- const configService = new ConfigService();
172
- const name = strategyName && strategyName.length > 0 ? strategyName : "default";
173
- const strategyService = new StrategyService(configService);
174
- strategyService.ensureConfigFileExists();
175
- const strategy = resolveStrategy({
176
- strategyName: name,
177
- strategyService
178
- });
179
- const units = await loadStrategyUnits({
180
- strategy,
181
- configService
182
- });
183
- const entryTarget = pagepocket;
184
- const strategyFile = strategy.strategyFile;
185
- const captureOptions = strategyFile.pipeline.captureOptions;
186
- const effectiveTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : captureOptions?.timeoutMs;
187
- const effectiveMaxDurationMs = typeof maxDurationMs === "number" ? maxDurationMs : captureOptions?.maxDurationMs;
188
- const result = await entryTarget.capture({
189
- ...(typeof effectiveTimeoutMs === "number" ? { timeoutMs: effectiveTimeoutMs } : {}),
190
- ...(typeof effectiveMaxDurationMs === "number"
191
- ? { maxDurationMs: effectiveMaxDurationMs }
192
- : {}),
193
- blacklist: [...ga, ...ns],
194
- units,
195
- plugins: await loadConfiguredPlugins().catch(() => [])
196
- });
197
- return result;
46
+ let spinner = ora();
47
+ let activeUnitId = "";
48
+ let activeUnitLabel = "";
49
+ const formatUnitLabel = (index, total, unitDescription) => `[${index + 1}/${total}] ${unitDescription}`;
50
+ pagepocket.on("unit:start", (e) => {
51
+ activeUnitId = e.unitId;
52
+ activeUnitLabel = formatUnitLabel(e.index, e.total, e.unitDescription);
53
+ spinner = ora(activeUnitLabel).start();
54
+ });
55
+ pagepocket.on("unit:log", (e) => {
56
+ if (e.unitId !== activeUnitId) {
57
+ return;
198
58
  }
199
- }, "Freezing page");
59
+ spinner.text = `${activeUnitLabel} ${chalk.gray(e.message)}`;
60
+ });
61
+ pagepocket.on("unit:end", (e) => {
62
+ spinner.succeed(formatUnitLabel(e.index, e.total, e.unitDescription));
63
+ });
64
+ let result;
65
+ try {
66
+ result = await pagepocket.capture({
67
+ ...(typeof captureOptions?.timeoutMs === "number"
68
+ ? { timeoutMs: captureOptions.timeoutMs }
69
+ : {}),
70
+ ...(typeof captureOptions?.maxDurationMs === "number"
71
+ ? { maxDurationMs: captureOptions.maxDurationMs }
72
+ : {}),
73
+ blacklist: [...ga, ...ns],
74
+ units,
75
+ plugins: await loadConfiguredPlugins()
76
+ });
77
+ }
78
+ catch (error) {
79
+ spinner.fail();
80
+ throw error;
81
+ }
200
82
  this.log(chalk.green("All done! Snapshot created."));
201
83
  if (result.kind === "zip") {
202
84
  this.log(`Snapshot saved to ${chalk.cyan(result.zip.outputPath)}`);
@@ -1,14 +1,19 @@
1
1
  import { Command } from "@oclif/core";
2
2
  import chalk from "chalk";
3
3
  import { listBuiltinStrategyNames, readBuiltinStrategy } from "../../services/strategy/builtin-strategy-registry.js";
4
- import { normalizeStrategyUnits } from "../../services/strategy/strategy-normalize.js";
4
+ import { normalizeBuiltinStrategyUnits, normalizeStrategyUnits } from "../../services/strategy/strategy-normalize.js";
5
5
  import { StrategyService } from "../../services/strategy/strategy-service.js";
6
6
  import { uniq } from "../../utils/array.js";
7
- const formatStrategy = (name, strategyFile) => {
7
+ const formatInstalledStrategy = (name, strategyFile) => {
8
8
  const units = normalizeStrategyUnits(strategyFile);
9
9
  const pipeline = units.map((unit) => unit.ref).join(" -> ");
10
10
  return pipeline.length > 0 ? `${name}${chalk.dim(`(${pipeline})`)}` : name;
11
11
  };
12
+ const formatBuiltinStrategy = (name, strategyFile) => {
13
+ const units = normalizeBuiltinStrategyUnits(strategyFile);
14
+ const pipeline = units.map((unit) => unit.name).join(" -> ");
15
+ return pipeline.length > 0 ? `${name}${chalk.dim(`(${pipeline})`)}` : name;
16
+ };
12
17
  export default class StrategyLsCommand extends Command {
13
18
  static description = "List available strategies.";
14
19
  async run() {
@@ -25,11 +30,13 @@ export default class StrategyLsCommand extends Command {
25
30
  return;
26
31
  }
27
32
  for (const name of names) {
28
- const strategyFile = installedNames.includes(name)
29
- ? service.readStrategy(name)
30
- : readBuiltinStrategy(name);
33
+ const isInstalled = installedNames.includes(name);
34
+ const strategyFile = isInstalled ? service.readStrategy(name) : readBuiltinStrategy(name);
31
35
  if (strategyFile) {
32
- this.log(formatStrategy(name, strategyFile));
36
+ const formatted = isInstalled
37
+ ? formatInstalledStrategy(name, strategyFile)
38
+ : formatBuiltinStrategy(name, strategyFile);
39
+ this.log(formatted);
33
40
  }
34
41
  else {
35
42
  this.log(name);
@@ -51,7 +51,7 @@ export default class ViewCommand extends Command {
51
51
  host: flags.host,
52
52
  port
53
53
  });
54
- this.log(`Serving ${chalk.cyan(rootDir)}`);
54
+ this.log(`Serving ${chalk.cyan(rootDir)} ${chalk.dim(`(${server.snapshotType})`)}`);
55
55
  this.log(chalk.green(server.url));
56
56
  }
57
57
  }
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  import { execute } from "@oclif/core";
2
3
  import { normalizeArgv } from "./utils/normalize-argv.js";
3
4
  await execute({
@@ -43,3 +43,41 @@ export const normalizeStrategyUnits = (strategy) => {
43
43
  const units = strategy.pipeline.units;
44
44
  return units.map((spec, idx) => normalizeUnitSpec(spec, idx));
45
45
  };
46
+ const parsePackageName = (spec) => {
47
+ const trimmed = spec.trim();
48
+ if (trimmed.startsWith("@")) {
49
+ const afterScope = trimmed.indexOf("/");
50
+ if (afterScope === -1) {
51
+ throw new Error(`Invalid scoped package spec: ${trimmed}`);
52
+ }
53
+ const versionSep = trimmed.indexOf("@", afterScope + 1);
54
+ return versionSep === -1 ? trimmed : trimmed.slice(0, versionSep);
55
+ }
56
+ const versionSep = trimmed.indexOf("@");
57
+ return versionSep === -1 ? trimmed : trimmed.slice(0, versionSep);
58
+ };
59
+ const normalizeBuiltinUnitSpec = (spec, idx) => {
60
+ if (typeof spec === "string") {
61
+ return { name: parsePackageName(spec), args: [] };
62
+ }
63
+ if (!isRecord(spec)) {
64
+ throw new Error(`Invalid unit spec at index ${idx}`);
65
+ }
66
+ const ref = spec.ref;
67
+ if (typeof ref !== "string") {
68
+ throw new Error(`Invalid unit ref at index ${idx}`);
69
+ }
70
+ const argsRaw = spec.args;
71
+ const args = typeof argsRaw === "undefined" ? [] : argsRaw;
72
+ if (!Array.isArray(args)) {
73
+ throw new Error(`Invalid unit args at index ${idx} (must be an array)`);
74
+ }
75
+ if (!isJsonValue(args)) {
76
+ throw new Error(`Invalid unit args at index ${idx} (must be JSON-only)`);
77
+ }
78
+ return { name: parsePackageName(ref), args };
79
+ };
80
+ export const normalizeBuiltinStrategyUnits = (strategy) => {
81
+ const units = strategy.pipeline.units;
82
+ return units.map((spec, idx) => normalizeBuiltinUnitSpec(spec, idx));
83
+ };
@@ -1,7 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { parsePinnedSpec } from "../user-packages/parse-pinned-spec.js";
3
- import { installPinnedPackage } from "../user-packages/user-package-installer.js";
4
- import { updatePackageToLatest } from "../user-packages/user-package-installer.js";
3
+ import { installPinnedPackage, updatePackageToLatest } from "../user-packages/user-package-installer.js";
5
4
  import { UserPackageStore } from "../user-packages/user-package-store.js";
6
5
  const STRATEGY_PACKS_KIND = "strategy-packs";
7
6
  const STRATEGY_PACKS_PACKAGE_JSON_NAME = "pagepocket-user-strategy-packs";
@@ -7,9 +7,6 @@ export const isUnitLike = (value) => {
7
7
  if (typeof value.id !== "string" || value.id.trim().length === 0) {
8
8
  return false;
9
9
  }
10
- if (typeof value.kind !== "string" || value.kind.trim().length === 0) {
11
- return false;
12
- }
13
10
  if (!isCallable(value.run)) {
14
11
  return false;
15
12
  }
@@ -7,7 +7,7 @@ import { Unit } from "@pagepocket/lib";
7
7
  */
8
8
  export class NetworkObserverUnit extends Unit {
9
9
  id = "networkObserver";
10
- kind = "observe.network";
10
+ description = "Observing network events";
11
11
  config;
12
12
  constructor(config = {}) {
13
13
  super();
@@ -0,0 +1,138 @@
1
+ import { createRequire } from "node:module";
2
+ import { pathToFileURL } from "node:url";
3
+ import { readBuiltinStrategy, listBuiltinStrategyNames } from "../services/strategy/builtin-strategy-registry.js";
4
+ import { normalizeBuiltinStrategyUnits, normalizeStrategyUnits } from "../services/strategy/strategy-normalize.js";
5
+ import { StrategyService } from "../services/strategy/strategy-service.js";
6
+ import { UnitStore } from "../services/units/unit-store.js";
7
+ import { isUnitLike, resolveUnitConstructor } from "../services/units/unit-validate.js";
8
+ import { parsePinnedSpec } from "../services/user-packages/parse-pinned-spec.js";
9
+ import { uniq } from "./array.js";
10
+ const buildMissingStrategyError = (input) => {
11
+ const available = uniq([...input.installedNames, ...listBuiltinStrategyNames()])
12
+ .filter((name) => name.trim().length > 0)
13
+ .sort((left, right) => left.localeCompare(right));
14
+ const suffix = available.length > 0
15
+ ? ` Available strategies: ${available.join(", ")}`
16
+ : " No strategies found.";
17
+ return new Error(`Strategy not found: ${input.strategyName}.${suffix}`);
18
+ };
19
+ const assertNoDuplicateUnitIds = (input) => {
20
+ const seen = new Set();
21
+ const duplicateUnit = input.units.find((unit) => {
22
+ if (seen.has(unit.id)) {
23
+ return true;
24
+ }
25
+ seen.add(unit.id);
26
+ return false;
27
+ });
28
+ if (duplicateUnit) {
29
+ throw new Error(`Duplicate unit id detected in strategy ${input.strategyName}: ${duplicateUnit.id}`);
30
+ }
31
+ };
32
+ const loadInstalledStrategyUnits = async (input) => {
33
+ const strategyService = new StrategyService(input.configService);
34
+ const unitStore = new UnitStore(input.configService);
35
+ strategyService.ensureConfigFileExists();
36
+ const strategyFile = strategyService.readStrategy(input.strategyName);
37
+ const normalized = normalizeStrategyUnits(strategyFile);
38
+ const installed = unitStore.readInstalledDependencyVersions();
39
+ const drift = normalized.flatMap((unit) => {
40
+ const pinned = parsePinnedSpec(unit.ref);
41
+ const installedVersion = installed[pinned.name];
42
+ if (!installedVersion || installedVersion !== pinned.version) {
43
+ return [
44
+ {
45
+ name: pinned.name,
46
+ pinned: pinned.version,
47
+ installed: installedVersion
48
+ }
49
+ ];
50
+ }
51
+ return [];
52
+ });
53
+ if (drift.length > 0) {
54
+ const details = drift
55
+ .map((driftItem) => {
56
+ const installedText = typeof driftItem.installed === "string" ? driftItem.installed : "(not installed)";
57
+ return `${driftItem.name}: pinned ${driftItem.pinned}, installed ${installedText}`;
58
+ })
59
+ .join("; ");
60
+ throw new Error(`Strategy drift detected (${input.strategyName}). ${details}. ` +
61
+ `Fix: pp strategy update ${input.strategyName} OR pp strategy pin ${input.strategyName}`);
62
+ }
63
+ const units = await Promise.all(normalized.map(async (unit) => unitStore.instantiateFromRef(unit.ref, unit.args)));
64
+ assertNoDuplicateUnitIds({
65
+ strategyName: input.strategyName,
66
+ units
67
+ });
68
+ return units;
69
+ };
70
+ const instantiateBuiltinUnit = async (input) => {
71
+ const req = createRequire(import.meta.url);
72
+ const resolved = req.resolve(input.name);
73
+ const mod = (await import(pathToFileURL(resolved).href));
74
+ const ctor = resolveUnitConstructor(mod);
75
+ if (!ctor) {
76
+ throw new Error(`Unit ${input.name} does not export a Unit constructor.`);
77
+ }
78
+ const instance = new ctor(...input.args);
79
+ if (!isUnitLike(instance)) {
80
+ throw new Error(`Unit ${input.name} exported constructor did not produce a Unit.`);
81
+ }
82
+ return instance;
83
+ };
84
+ const loadBuiltinStrategyUnits = async (input) => {
85
+ const normalized = normalizeBuiltinStrategyUnits(input.strategyFile);
86
+ const units = await Promise.all(normalized.map(async (unit) => instantiateBuiltinUnit(unit)));
87
+ assertNoDuplicateUnitIds({
88
+ strategyName: input.strategyName,
89
+ units
90
+ });
91
+ return units;
92
+ };
93
+ /**
94
+ * Resolve strategy by name from installed strategies first, then built-in strategies.
95
+ *
96
+ * Usage:
97
+ * const strategy = resolveStrategy({ strategyName: "default", strategyService });
98
+ */
99
+ export const resolveStrategy = (input) => {
100
+ const installedNames = input.strategyService.listInstalledStrategyNames();
101
+ if (installedNames.includes(input.strategyName)) {
102
+ return {
103
+ name: input.strategyName,
104
+ strategyFile: input.strategyService.readStrategy(input.strategyName),
105
+ source: "installed"
106
+ };
107
+ }
108
+ const builtin = readBuiltinStrategy(input.strategyName);
109
+ if (builtin) {
110
+ return {
111
+ name: input.strategyName,
112
+ strategyFile: builtin,
113
+ source: "builtin"
114
+ };
115
+ }
116
+ throw buildMissingStrategyError({
117
+ strategyName: input.strategyName,
118
+ installedNames
119
+ });
120
+ };
121
+ /**
122
+ * Load executable unit instances for a resolved strategy.
123
+ *
124
+ * Usage:
125
+ * const units = await loadStrategyUnits({ strategy, configService });
126
+ */
127
+ export const loadStrategyUnits = async (input) => {
128
+ if (input.strategy.source === "installed") {
129
+ return loadInstalledStrategyUnits({
130
+ strategyName: input.strategy.name,
131
+ configService: input.configService
132
+ });
133
+ }
134
+ return loadBuiltinStrategyUnits({
135
+ strategyName: input.strategy.name,
136
+ strategyFile: input.strategy.strategyFile
137
+ });
138
+ };
@@ -0,0 +1,117 @@
1
+ const VAR_PATTERN = /\{\{([a-zA-Z_][a-zA-Z0-9_]*)(?::([^}]*))?\}\}/g;
2
+ /**
3
+ * Parse `-e` flag values into a typed variable map.
4
+ *
5
+ * Each raw arg must be in `key:value` format. The key is the variable name,
6
+ * everything after the first colon is the raw value string.
7
+ *
8
+ * Value type inference:
9
+ * - Parseable as finite number → number
10
+ * - `"true"` / `"false"` → boolean
11
+ * - Otherwise → string
12
+ *
13
+ * Usage:
14
+ * parseEnvVars(["timeout:3000", "overwrite:true", "outDir:./snapshots"])
15
+ * // => { timeout: 3000, overwrite: true, outDir: "./snapshots" }
16
+ */
17
+ export const parseEnvVars = (rawArgs) => {
18
+ const vars = {};
19
+ for (const raw of rawArgs) {
20
+ const colonIndex = raw.indexOf(":");
21
+ if (colonIndex === -1) {
22
+ throw new Error(`Invalid -e value: "${raw}". Expected format: key:value (e.g. -e "timeout:3000")`);
23
+ }
24
+ const key = raw.slice(0, colonIndex).trim();
25
+ const valueRaw = raw.slice(colonIndex + 1);
26
+ if (key.length === 0) {
27
+ throw new Error(`Invalid -e value: "${raw}". Key must not be empty.`);
28
+ }
29
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
30
+ throw new Error(`Invalid variable name: "${key}". Must be a valid identifier (letters, digits, underscores).`);
31
+ }
32
+ if (key in vars) {
33
+ throw new Error(`Duplicate variable: "${key}". Each variable can only be specified once.`);
34
+ }
35
+ vars[key] = inferValue(valueRaw);
36
+ }
37
+ return vars;
38
+ };
39
+ const inferValue = (raw) => {
40
+ const asNumber = Number(raw);
41
+ if (raw.length > 0 && Number.isFinite(asNumber)) {
42
+ return asNumber;
43
+ }
44
+ if (raw === "true") {
45
+ return true;
46
+ }
47
+ if (raw === "false") {
48
+ return false;
49
+ }
50
+ return raw;
51
+ };
52
+ /**
53
+ * Recursively resolve `{{varName}}` and `{{varName:default}}` placeholders in a JSON structure.
54
+ *
55
+ * - Only string values are scanned for placeholders.
56
+ * - `{{var:default}}` provides an inline default; the default is type-inferred the same way as `-e` values.
57
+ * - If the entire string is a single `{{var}}` or `{{var:default}}`, the value is replaced with the
58
+ * variable's typed value (number, boolean, or string).
59
+ * - If the string contains mixed text and placeholders (e.g. `"path/{{name}}"`),
60
+ * all placeholders are interpolated and the result remains a string.
61
+ * - Throws if any referenced variable has no `-e` value AND no inline default.
62
+ *
63
+ * Usage:
64
+ * resolveStrategyVars({ timeoutMs: "{{timeout:5000}}" }, {})
65
+ * // => { timeoutMs: 5000 }
66
+ */
67
+ export const resolveStrategyVars = (json, vars) => {
68
+ const defaults = new Map();
69
+ const referenced = new Set();
70
+ const resolved = resolveNode(json, vars, defaults, referenced);
71
+ const missing = [...referenced].filter((name) => !(name in vars) && !defaults.has(name));
72
+ if (missing.length > 0) {
73
+ throw new Error(`Missing strategy variable(s): ${missing.join(", ")}. ` +
74
+ `Pass them with ${missing.map((name) => `-e "${name}:<value>"`).join(" ")}`);
75
+ }
76
+ return resolved;
77
+ };
78
+ const resolveNode = (node, vars, defaults, referenced) => {
79
+ if (typeof node === "string") {
80
+ return resolveString(node, vars, defaults, referenced);
81
+ }
82
+ if (Array.isArray(node)) {
83
+ return node.map((item) => resolveNode(item, vars, defaults, referenced));
84
+ }
85
+ if (isRecord(node)) {
86
+ const result = {};
87
+ for (const [key, value] of Object.entries(node)) {
88
+ result[key] = resolveNode(value, vars, defaults, referenced);
89
+ }
90
+ return result;
91
+ }
92
+ return node;
93
+ };
94
+ const resolveString = (value, vars, defaults, referenced) => {
95
+ const matches = [...value.matchAll(VAR_PATTERN)];
96
+ if (matches.length === 0) {
97
+ return value;
98
+ }
99
+ for (const match of matches) {
100
+ const varName = match[1];
101
+ const rawDefault = match[2];
102
+ referenced.add(varName);
103
+ if (rawDefault !== undefined && !defaults.has(varName)) {
104
+ defaults.set(varName, inferValue(rawDefault));
105
+ }
106
+ }
107
+ const isSingleWholeVar = matches.length === 1 && matches[0][0] === value;
108
+ if (isSingleWholeVar) {
109
+ const varName = matches[0][1];
110
+ return vars[varName] ?? defaults.get(varName);
111
+ }
112
+ return value.replace(VAR_PATTERN, (_match, varName) => {
113
+ const resolved = vars[varName] ?? defaults.get(varName);
114
+ return String(resolved ?? "");
115
+ });
116
+ };
117
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);