@pagepocket/cli 0.11.1 → 0.13.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.
Files changed (31) hide show
  1. package/dist/commands/archive.js +46 -161
  2. package/dist/commands/plugin/set.js +1 -3
  3. package/dist/commands/strategy/doctor.js +6 -6
  4. package/dist/commands/strategy/ls.js +32 -8
  5. package/dist/commands/view.js +1 -1
  6. package/dist/services/config-service.js +8 -8
  7. package/dist/services/plugin-installer.js +10 -10
  8. package/dist/services/plugin-store.js +11 -13
  9. package/dist/services/strategy/builtin-strategy-registry.js +12 -21
  10. package/dist/services/strategy/strategy-analyze.js +6 -6
  11. package/dist/services/strategy/strategy-config.js +4 -4
  12. package/dist/services/strategy/strategy-fetch.js +2 -4
  13. package/dist/services/strategy/strategy-io.js +3 -9
  14. package/dist/services/strategy/strategy-normalize.js +43 -7
  15. package/dist/services/strategy/strategy-pack-read.js +2 -4
  16. package/dist/services/strategy/strategy-pack-store.js +1 -2
  17. package/dist/services/strategy/strategy-service.js +63 -54
  18. package/dist/services/units/unit-store.js +1 -1
  19. package/dist/services/units/unit-validate.js +4 -10
  20. package/dist/services/user-packages/parse-pinned-spec.js +3 -7
  21. package/dist/services/user-packages/user-package-installer.js +5 -5
  22. package/dist/services/user-packages/user-package-store.js +8 -8
  23. package/dist/units/network-observer-unit.js +1 -1
  24. package/dist/utils/archive-strategy.js +138 -0
  25. package/dist/utils/array.js +1 -3
  26. package/dist/utils/parse-plugin-spec.js +1 -1
  27. package/dist/vendor/content-reader.css +1 -0
  28. package/dist/vendor/content-reader.iife.js +60 -0
  29. package/dist/view-main-content.js +40 -0
  30. package/dist/view.js +71 -33
  31. package/package.json +21 -19
@@ -1,20 +1,18 @@
1
1
  import { parsePinnedSpec } from "../user-packages/parse-pinned-spec.js";
2
- const isRecord = (value) => {
3
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
4
- };
2
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
5
3
  const isJsonValue = (value) => {
6
4
  if (value === null) {
7
5
  return true;
8
6
  }
9
- const t = typeof value;
10
- if (t === "string" || t === "number" || t === "boolean") {
7
+ const valueType = typeof value;
8
+ if (valueType === "string" || valueType === "number" || valueType === "boolean") {
11
9
  return true;
12
10
  }
13
11
  if (Array.isArray(value)) {
14
- return value.every((v) => isJsonValue(v));
12
+ return value.every((item) => isJsonValue(item));
15
13
  }
16
14
  if (isRecord(value)) {
17
- return Object.values(value).every((v) => isJsonValue(v));
15
+ return Object.values(value).every((entryValue) => isJsonValue(entryValue));
18
16
  }
19
17
  return false;
20
18
  };
@@ -45,3 +43,41 @@ export const normalizeStrategyUnits = (strategy) => {
45
43
  const units = strategy.pipeline.units;
46
44
  return units.map((spec, idx) => normalizeUnitSpec(spec, idx));
47
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,9 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { parseJson } from "../../utils/parse-json.js";
4
- const isRecord = (value) => {
5
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
6
- };
4
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
7
5
  const isStrategyFile = (value) => {
8
6
  if (!isRecord(value)) {
9
7
  return false;
@@ -30,7 +28,7 @@ export const readStrategiesFromPackRoot = (packRoot) => {
30
28
  }
31
29
  const fileNames = fs
32
30
  .readdirSync(strategiesDir)
33
- .filter((f) => f.endsWith(".strategy.json"))
31
+ .filter((fileName) => fileName.endsWith(".strategy.json"))
34
32
  .sort((a, b) => a.localeCompare(b));
35
33
  return fileNames.map((fileName) => {
36
34
  const filePath = path.join(strategiesDir, fileName);
@@ -1,7 +1,6 @@
1
1
  import path from "node:path";
2
- import { installPinnedPackage } from "../user-packages/user-package-installer.js";
3
- import { updatePackageToLatest } from "../user-packages/user-package-installer.js";
4
2
  import { parsePinnedSpec } from "../user-packages/parse-pinned-spec.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";
@@ -1,8 +1,8 @@
1
1
  import fs from "node:fs";
2
+ import { uniq } from "../../utils/array.js";
2
3
  import { ConfigService } from "../config-service.js";
3
- import { parsePinnedSpec } from "../user-packages/parse-pinned-spec.js";
4
4
  import { UnitStore } from "../units/unit-store.js";
5
- import { uniq } from "../../utils/array.js";
5
+ import { parsePinnedSpec } from "../user-packages/parse-pinned-spec.js";
6
6
  import { collectWantedVersions, computeConflicts, computeDrift, ensureNoInstalledVersionConflicts } from "./strategy-analyze.js";
7
7
  import { listStrategyNamesFromConfig, requireStrategyInstalled, withStrategyInConfig, withoutStrategyInConfig } from "./strategy-config.js";
8
8
  import { fetchStrategyFile } from "./strategy-fetch.js";
@@ -55,8 +55,8 @@ export class StrategyService {
55
55
  if (strategies.length === 0) {
56
56
  throw new Error(`No *.strategy.json found in package: ${pinned.spec}`);
57
57
  }
58
- const allUnits = strategies.flatMap((s) => normalizeStrategyUnits(s));
59
- const refs = uniq(allUnits.map((u) => u.ref));
58
+ const allUnits = strategies.flatMap((strategy) => normalizeStrategyUnits(strategy));
59
+ const refs = uniq(allUnits.map((unit) => unit.ref));
60
60
  ensureNoInstalledVersionConflicts(this.unitStore.readInstalledDependencyVersions(), refs);
61
61
  refs.forEach((ref) => this.unitStore.installPinned(ref));
62
62
  const installedStrategies = [];
@@ -73,13 +73,13 @@ export class StrategyService {
73
73
  writeJsonAtomic(strategyPath, toWrite);
74
74
  installedStrategies.push(name);
75
75
  });
76
- const nextConfig = installedStrategies.reduce((acc, s) => withStrategyInConfig(acc, s), config);
76
+ const nextConfig = installedStrategies.reduce((configAccumulator, strategyName) => withStrategyInConfig(configAccumulator, strategyName), config);
77
77
  const existing = nextConfig.strategyPacks ?? [];
78
- const filtered = existing.filter((x) => {
79
- if (typeof x === "string") {
80
- return x.trim().length > 0;
78
+ const filtered = existing.filter((packEntry) => {
79
+ if (typeof packEntry === "string") {
80
+ return packEntry.trim().length > 0;
81
81
  }
82
- return x.name !== pinned.name;
82
+ return packEntry.name !== pinned.name;
83
83
  });
84
84
  const strategyPacks = [
85
85
  ...filtered,
@@ -93,7 +93,7 @@ export class StrategyService {
93
93
  }
94
94
  const { strategy, source } = await fetchStrategyFile(sourceTrimmed);
95
95
  const normalizedUnits = normalizeStrategyUnits(strategy);
96
- const refs = uniq(normalizedUnits.map((u) => u.ref));
96
+ const refs = uniq(normalizedUnits.map((unit) => unit.ref));
97
97
  const strategyPath = this.getStrategyPath(strategy.name);
98
98
  if (!input.force && fs.existsSync(strategyPath)) {
99
99
  throw new Error(`Strategy already exists: ${strategy.name}`);
@@ -128,15 +128,15 @@ export class StrategyService {
128
128
  const installed = this.unitStore.readInstalledDependencyVersions();
129
129
  if (opts?.packageOnly) {
130
130
  const packageNames = uniq(names
131
- .flatMap((n) => normalizeStrategyUnits(this.readStrategy(n)).map((u) => parsePinnedSpec(u.ref).name))
132
- .filter((x) => x.trim().length > 0));
131
+ .flatMap((strategyName) => normalizeStrategyUnits(this.readStrategy(strategyName)).map((unit) => parsePinnedSpec(unit.ref).name))
132
+ .filter((packageName) => packageName.trim().length > 0));
133
133
  packageNames.forEach((pkg) => {
134
134
  this.unitStore.updateToLatest(pkg);
135
135
  });
136
136
  const afterInstalled = this.unitStore.readInstalledDependencyVersions();
137
- const afterStrategies = listStrategyNamesFromConfig(config).map((n) => ({
138
- name: n,
139
- units: normalizeStrategyUnits(this.readStrategy(n))
137
+ const afterStrategies = listStrategyNamesFromConfig(config).map((strategyName) => ({
138
+ name: strategyName,
139
+ units: normalizeStrategyUnits(this.readStrategy(strategyName))
140
140
  }));
141
141
  const wanted = collectWantedVersions(afterStrategies);
142
142
  const conflicts = computeConflicts(wanted);
@@ -144,8 +144,12 @@ export class StrategyService {
144
144
  throw new Error(`Strategy version conflicts detected (${conflicts.length}). Run 'pp strategy doctor' for details.`);
145
145
  }
146
146
  const drift = afterStrategies
147
- .map((s) => computeDrift({ strategyName: s.name, units: s.units, installed: afterInstalled }))
148
- .filter((d) => d.items.length > 0);
147
+ .map((strategy) => computeDrift({
148
+ strategyName: strategy.name,
149
+ units: strategy.units,
150
+ installed: afterInstalled
151
+ }))
152
+ .filter((drift) => drift.items.length > 0);
149
153
  if (drift.length > 0) {
150
154
  throw new Error("Strategy drift detected after --package-only update. Run 'pp strategy doctor'.");
151
155
  }
@@ -153,23 +157,23 @@ export class StrategyService {
153
157
  }
154
158
  const nextStrategies = [];
155
159
  const npmPacksToUpdate = new Set();
156
- for (const n of names) {
157
- const current = this.readStrategy(n);
160
+ for (const strategyName of names) {
161
+ const current = this.readStrategy(strategyName);
158
162
  const src = current.source;
159
163
  if (!src) {
160
- throw new Error(`Strategy ${n} has no source. Re-add it with a URL/path source to enable update.`);
164
+ throw new Error(`Strategy ${strategyName} has no source. Re-add it with a URL/path source to enable update.`);
161
165
  }
162
166
  if (src.type === "npm") {
163
167
  npmPacksToUpdate.add(src.value);
164
168
  continue;
165
169
  }
166
170
  const fetched = await fetchStrategyFile(src.value);
167
- if (fetched.strategy.name !== n) {
168
- throw new Error(`Strategy name mismatch while updating ${n}: got ${fetched.strategy.name}`);
171
+ if (fetched.strategy.name !== strategyName) {
172
+ throw new Error(`Strategy name mismatch while updating ${strategyName}: got ${fetched.strategy.name}`);
169
173
  }
170
174
  const file = { ...fetched.strategy, source: fetched.source };
171
175
  const units = normalizeStrategyUnits(file);
172
- nextStrategies.push({ name: n, file, units });
176
+ nextStrategies.push({ name: strategyName, file, units });
173
177
  }
174
178
  if (npmPacksToUpdate.size > 0) {
175
179
  const specs = [...npmPacksToUpdate];
@@ -180,18 +184,18 @@ export class StrategyService {
180
184
  const installedPackVersions = this.packStore.readInstalledDependencyVersions();
181
185
  const updatedSpecs = specs.map((spec) => {
182
186
  const pinned = parsePinnedSpec(spec);
183
- const v = installedPackVersions[pinned.name];
184
- if (!v) {
187
+ const installedVersion = installedPackVersions[pinned.name];
188
+ if (!installedVersion) {
185
189
  throw new Error(`Strategy pack not installed after update: ${pinned.name}`);
186
190
  }
187
- return `${pinned.name}@${v}`;
191
+ return `${pinned.name}@${installedVersion}`;
188
192
  });
189
193
  const updatedFiles = [];
190
194
  updatedSpecs.forEach((spec) => {
191
195
  const pinned = parsePinnedSpec(spec);
192
196
  const root = this.packStore.resolvePackRoot(pinned.name);
193
- const files = readStrategiesFromPackRoot(root).map((f) => ({
194
- ...f,
197
+ const files = readStrategiesFromPackRoot(root).map((strategyFile) => ({
198
+ ...strategyFile,
195
199
  source: { type: "npm", value: spec }
196
200
  }));
197
201
  updatedFiles.push(...files);
@@ -202,11 +206,11 @@ export class StrategyService {
202
206
  });
203
207
  const nextConfig = this.configService.readConfigOrDefault();
204
208
  const existing = nextConfig.strategyPacks ?? [];
205
- const filtered = existing.filter((x) => {
206
- if (typeof x === "string") {
207
- return x.trim().length > 0;
209
+ const filtered = existing.filter((packEntry) => {
210
+ if (typeof packEntry === "string") {
211
+ return packEntry.trim().length > 0;
208
212
  }
209
- return !updatedSpecs.some((spec) => parsePinnedSpec(spec).name === x.name);
213
+ return !updatedSpecs.some((spec) => parsePinnedSpec(spec).name === packEntry.name);
210
214
  });
211
215
  const packsNext = [
212
216
  ...filtered,
@@ -217,26 +221,26 @@ export class StrategyService {
217
221
  ];
218
222
  this.configService.writeConfig({ ...nextConfig, strategyPacks: packsNext });
219
223
  }
220
- const allOtherNames = listStrategyNamesFromConfig(config).filter((n) => !names.includes(n));
221
- const otherStrategies = allOtherNames.map((n) => ({
222
- name: n,
223
- units: normalizeStrategyUnits(this.readStrategy(n))
224
+ const allOtherNames = listStrategyNamesFromConfig(config).filter((strategyName) => !names.includes(strategyName));
225
+ const otherStrategies = allOtherNames.map((strategyName) => ({
226
+ name: strategyName,
227
+ units: normalizeStrategyUnits(this.readStrategy(strategyName))
224
228
  }));
225
229
  const wanted = collectWantedVersions([
226
230
  ...otherStrategies,
227
- ...nextStrategies.map((s) => ({ name: s.name, units: s.units }))
231
+ ...nextStrategies.map((strategy) => ({ name: strategy.name, units: strategy.units }))
228
232
  ]);
229
233
  const conflicts = computeConflicts(wanted);
230
234
  if (conflicts.length > 0) {
231
235
  throw new Error(`Strategy version conflicts detected (${conflicts.length}). Run 'pp strategy doctor' for details.`);
232
236
  }
233
- const refs = uniq(nextStrategies.flatMap((s) => s.units.map((u) => u.ref)));
237
+ const refs = uniq(nextStrategies.flatMap((strategy) => strategy.units.map((unit) => unit.ref)));
234
238
  ensureNoInstalledVersionConflicts(installed, refs);
235
239
  refs.forEach((ref) => {
236
240
  this.unitStore.installPinned(ref);
237
241
  });
238
- nextStrategies.forEach((s) => {
239
- writeJsonAtomic(this.getStrategyPath(s.name), s.file);
242
+ nextStrategies.forEach((strategy) => {
243
+ writeJsonAtomic(this.getStrategyPath(strategy.name), strategy.file);
240
244
  });
241
245
  }
242
246
  pinStrategy(name) {
@@ -246,23 +250,28 @@ export class StrategyService {
246
250
  const file = this.readStrategy(name);
247
251
  const units = normalizeStrategyUnits(file);
248
252
  const installed = this.unitStore.readInstalledDependencyVersions();
249
- const pinnedUnits = units.map((u) => {
250
- const pinned = parsePinnedSpec(u.ref);
251
- const v = installed[pinned.name];
252
- if (!v) {
253
+ const pinnedUnits = units.map((unit) => {
254
+ const pinned = parsePinnedSpec(unit.ref);
255
+ const installedVersion = installed[pinned.name];
256
+ if (!installedVersion) {
253
257
  throw new Error(`Unit package is not installed: ${pinned.name}`);
254
258
  }
255
- const nextRef = `${pinned.name}@${v}`;
256
- return u.args.length === 0 ? nextRef : { ref: nextRef, args: u.args };
259
+ const nextRef = `${pinned.name}@${installedVersion}`;
260
+ return unit.args.length === 0 ? nextRef : { ref: nextRef, args: unit.args };
257
261
  });
258
262
  const others = listStrategyNamesFromConfig(config)
259
- .filter((n) => n !== name)
260
- .map((n) => ({ name: n, units: normalizeStrategyUnits(this.readStrategy(n)) }));
263
+ .filter((strategyName) => strategyName !== name)
264
+ .map((strategyName) => ({
265
+ name: strategyName,
266
+ units: normalizeStrategyUnits(this.readStrategy(strategyName))
267
+ }));
261
268
  const nextWanted = collectWantedVersions([
262
269
  ...others,
263
270
  {
264
271
  name,
265
- units: pinnedUnits.map((x) => typeof x === "string" ? { ref: x, args: [] } : { ref: x.ref, args: x.args ?? [] })
272
+ units: pinnedUnits.map((pinnedUnit) => typeof pinnedUnit === "string"
273
+ ? { ref: pinnedUnit, args: [] }
274
+ : { ref: pinnedUnit.ref, args: pinnedUnit.args ?? [] })
266
275
  }
267
276
  ]);
268
277
  const conflicts = computeConflicts(nextWanted);
@@ -283,15 +292,15 @@ export class StrategyService {
283
292
  const config = this.configService.readConfigOrDefault();
284
293
  const names = listStrategyNamesFromConfig(config);
285
294
  const installed = this.unitStore.readInstalledDependencyVersions();
286
- const strategies = names.map((n) => ({
287
- name: n,
288
- units: normalizeStrategyUnits(this.readStrategy(n))
295
+ const strategies = names.map((strategyName) => ({
296
+ name: strategyName,
297
+ units: normalizeStrategyUnits(this.readStrategy(strategyName))
289
298
  }));
290
299
  const wanted = collectWantedVersions(strategies);
291
300
  const conflicts = computeConflicts(wanted);
292
301
  const drift = strategies
293
- .map((s) => computeDrift({ strategyName: s.name, units: s.units, installed }))
294
- .filter((d) => d.items.length > 0);
302
+ .map((strategy) => computeDrift({ strategyName: strategy.name, units: strategy.units, installed }))
303
+ .filter((drift) => drift.items.length > 0);
295
304
  return { conflicts, drift };
296
305
  }
297
306
  }
@@ -1,5 +1,5 @@
1
- import { installPinnedPackage, updatePackageToLatest } from "../user-packages/user-package-installer.js";
2
1
  import { parsePinnedSpec } from "../user-packages/parse-pinned-spec.js";
2
+ import { installPinnedPackage, updatePackageToLatest } from "../user-packages/user-package-installer.js";
3
3
  import { UserPackageStore } from "../user-packages/user-package-store.js";
4
4
  import { isUnitLike, resolveUnitConstructor } from "./unit-validate.js";
5
5
  const UNITS_KIND = "units";
@@ -1,9 +1,5 @@
1
- const isRecord = (value) => {
2
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
3
- };
4
- const isCallable = (value) => {
5
- return typeof value === "function";
6
- };
1
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
2
+ const isCallable = (value) => typeof value === "function";
7
3
  export const isUnitLike = (value) => {
8
4
  if (!isRecord(value)) {
9
5
  return false;
@@ -11,9 +7,6 @@ export const isUnitLike = (value) => {
11
7
  if (typeof value.id !== "string" || value.id.trim().length === 0) {
12
8
  return false;
13
9
  }
14
- if (typeof value.kind !== "string" || value.kind.trim().length === 0) {
15
- return false;
16
- }
17
10
  if (!isCallable(value.run)) {
18
11
  return false;
19
12
  }
@@ -35,7 +28,8 @@ export const resolveUnitConstructor = (mod) => {
35
28
  if (typeof mod.default === "function") {
36
29
  return mod.default;
37
30
  }
38
- const fallback = Object.values(mod).find((v) => typeof v === "function" && v.name?.endsWith("Unit"));
31
+ const fallback = Object.values(mod).find((exportedValue) => typeof exportedValue === "function" &&
32
+ exportedValue.name?.endsWith("Unit"));
39
33
  if (typeof fallback === "function") {
40
34
  return fallback;
41
35
  }
@@ -1,12 +1,8 @@
1
- import path from "node:path";
2
1
  import { createRequire } from "node:module";
2
+ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- const isRecord = (value) => {
5
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
6
- };
7
- const isExactSemver = (value) => {
8
- return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value);
9
- };
4
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
5
+ const isExactSemver = (value) => /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(value);
10
6
  export const parsePinnedSpec = (input) => {
11
7
  const trimmed = input.trim();
12
8
  if (!trimmed) {
@@ -6,16 +6,16 @@ const toError = (value) => {
6
6
  return new Error(String(value));
7
7
  };
8
8
  const tryRun = (cwd, cmd, args) => {
9
- const r = spawnSync(cmd, args, {
9
+ const result = spawnSync(cmd, args, {
10
10
  cwd,
11
11
  stdio: "inherit",
12
12
  shell: false
13
13
  });
14
- if (r.error) {
15
- return { ok: false, error: toError(r.error) };
14
+ if (result.error) {
15
+ return { ok: false, error: toError(result.error) };
16
16
  }
17
- if (typeof r.status === "number" && r.status !== 0) {
18
- return { ok: false, error: new Error(`${cmd} exited with code ${r.status}`) };
17
+ if (typeof result.status === "number" && result.status !== 0) {
18
+ return { ok: false, error: new Error(`${cmd} exited with code ${result.status}`) };
19
19
  }
20
20
  return { ok: true };
21
21
  };
@@ -3,14 +3,12 @@ import { createRequire } from "node:module";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { parseJson } from "../../utils/parse-json.js";
6
- const isRecord = (value) => {
7
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
8
- };
6
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
9
7
  const isStringRecord = (value) => {
10
8
  if (!value || typeof value !== "object" || Array.isArray(value)) {
11
9
  return false;
12
10
  }
13
- return Object.values(value).every((v) => typeof v === "string");
11
+ return Object.values(value).every((entryValue) => typeof entryValue === "string");
14
12
  };
15
13
  export class UserPackageStore {
16
14
  configService;
@@ -55,11 +53,13 @@ export class UserPackageStore {
55
53
  if (!json.ok || !isRecord(json.value)) {
56
54
  return { name: packageName };
57
55
  }
58
- const o = json.value;
56
+ const packageJsonRecord = json.value;
59
57
  return {
60
- name: typeof o.name === "string" ? o.name : packageName,
61
- description: typeof o.description === "string" ? o.description : undefined,
62
- version: typeof o.version === "string" ? o.version : undefined
58
+ name: typeof packageJsonRecord.name === "string" ? packageJsonRecord.name : packageName,
59
+ description: typeof packageJsonRecord.description === "string"
60
+ ? packageJsonRecord.description
61
+ : undefined,
62
+ version: typeof packageJsonRecord.version === "string" ? packageJsonRecord.version : undefined
63
63
  };
64
64
  }
65
65
  catch {
@@ -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
+ };
@@ -1,3 +1 @@
1
- export const uniq = (items) => {
2
- return [...new Set(items)];
3
- };
1
+ export const uniq = (items) => [...new Set(items)];
@@ -1,5 +1,5 @@
1
- import path from "node:path";
2
1
  import { createRequire } from "node:module";
2
+ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  export const parsePluginSpec = (spec) => {
5
5
  const trimmed = spec.trim();