@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.
- package/dist/commands/archive.js +53 -171
- package/dist/commands/strategy/ls.js +13 -6
- package/dist/commands/view.js +1 -1
- package/dist/index.js +1 -0
- package/dist/services/strategy/strategy-normalize.js +38 -0
- package/dist/services/strategy/strategy-pack-store.js +1 -2
- package/dist/services/units/unit-validate.js +0 -3
- package/dist/units/network-observer-unit.js +1 -1
- package/dist/utils/archive-strategy.js +138 -0
- package/dist/utils/strategy-vars.js +117 -0
- package/dist/vendor/content-reader.css +1 -0
- package/dist/vendor/content-reader.iife.js +60 -0
- package/dist/view-main-content.js +40 -0
- package/dist/view.js +71 -33
- package/package.json +24 -22
package/dist/commands/archive.js
CHANGED
|
@@ -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 {
|
|
12
|
-
import {
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
29
|
-
|
|
30
|
-
: readBuiltinStrategy(name);
|
|
33
|
+
const isInstalled = installedNames.includes(name);
|
|
34
|
+
const strategyFile = isInstalled ? service.readStrategy(name) : readBuiltinStrategy(name);
|
|
31
35
|
if (strategyFile) {
|
|
32
|
-
|
|
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);
|
package/dist/commands/view.js
CHANGED
|
@@ -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
|
@@ -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
|
}
|
|
@@ -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);
|