vydanne 0.14.0 → 1.0.0-rc.1

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
@@ -440,6 +440,46 @@ don't have to learn them the hard way.
440
440
 
441
441
  <br>
442
442
 
443
+ ## Middleware — your own checks around any command
444
+
445
+ A release has rules that belong to your app, not to vydanne: "never upload a bundle that has not opened on the lowest
446
+ Android we support", "tell the channel when a build reaches testers", "refuse a Friday". `middleware` in the config is
447
+ where that logic goes, so you do not wrap the CLI in a script that someone can bypass by calling vydanne directly.
448
+
449
+ ```js
450
+ // vydanne.config.mjs
451
+ export default {
452
+ // …
453
+ middleware: [
454
+ // an object limits an entry to the commands and stores it names
455
+ {
456
+ name: "release-gate",
457
+ commands: ["prerelease"],
458
+ stores: ["google"],
459
+ async run(ctx, next) {
460
+ if (ctx.apply && !hasReceipt(ctx.config.google.aab)) ctx.fail("this bundle has not passed the legacy-device check");
461
+ const result = await next(); // the command itself
462
+ if (ctx.apply && result?.ok) await tell("build is with testers");
463
+ return result; // returning nothing keeps the command's own result
464
+ },
465
+ },
466
+ // a bare function runs for every store command
467
+ async (ctx, next) => { ctx.log(`${ctx.command} on ${ctx.store}`); return next(); },
468
+ ],
469
+ };
470
+ ```
471
+
472
+ The first entry is outermost. Before `await next()` you may refuse: `ctx.fail(reason)` stops the command, prints
473
+ `refused by middleware release-gate: …` and exits 1, before any client exists, so a refusal costs no Play or App Store
474
+ Connect request. After it you see the result (`{ ok, planned }`). An entry that never calls `next()` skips the command and
475
+ says so. `ctx` carries `command`, `store`, `apply`, `dryRun`, `writes`, `argv`, `cwd`, `env`, `version`, and the live
476
+ `config`: a change made before `next()` (a different `google.track`, say) is what the command sees.
477
+
478
+ It wraps every store command, from the CLI and from `runCommand`. It does not wrap `auth`, `locales`, `version` or help, which
479
+ touch no store. A malformed entry is an error when the config loads. Middleware runs with the full power of the file it
480
+ lives in, so it is not a sandbox. The same `middleware` key, with the same semantics, is in
481
+ [zdymak](https://github.com/Lonli-Lokli/zdymak), so a check written for one reads the same in the other.
482
+
443
483
  ## What vydanne will never do
444
484
 
445
485
  - **It never submits for review, and never ships to the public.** It *will* put a build in front of
package/SKILL.md CHANGED
@@ -397,6 +397,14 @@ It waits for the version to actually leave review before returning. Apple passes
397
397
  `CANCELING` first, and a command that returned there would send you straight into a `fill` that
398
398
  fails on a still-locked version.
399
399
 
400
+ ## `middleware` — the app's own logic around commands
401
+
402
+ The config key `middleware` takes `(ctx, next)` functions or `{ name, commands, stores, run }` objects, first entry outermost.
403
+ Before `await next()` an entry may refuse with `ctx.fail(reason)`; after it, it sees `{ ok, planned }`. It runs for every store
404
+ command (CLI and `runCommand`), before any client exists, and not for `auth`/`locales`/`version`/help. Use it for a release
405
+ gate or a notifier rather than a wrapper script, which a direct `vydanne prerelease --apply` bypasses. Full example: README,
406
+ "Middleware". The same key exists in zdymak.
407
+
400
408
  ## `--apply` — writes are opt-in
401
409
 
402
410
  **Every store-mutating command is a DRY RUN without `--apply`**: `prepare` · `push` · `fill` ·
package/bin/vydanne.mjs CHANGED
@@ -6,6 +6,7 @@ import { loadConfig } from "../src/config.mjs";
6
6
  import { Client } from "../src/client.mjs";
7
7
  import { COMMANDS, PLAY_COMMANDS } from "../src/registry.mjs";
8
8
  import { yellow } from "../src/util.mjs";
9
+ import { runMiddleware } from "../src/middleware.mjs";
9
10
 
10
11
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url))).version;
11
12
 
@@ -33,6 +34,12 @@ if (store !== "apple" && store !== "google") {
33
34
  // keep working unchanged; `--apply` is what the docs teach.
34
35
  const apply = argv.includes("--apply") || process.env.VYDANNE_COMMIT === "1";
35
36
 
37
+ // USER MIDDLEWARE (config `middleware`) wraps every STORE command. Not `auth`, `locales`, `version` or help: those
38
+ // touch no store, so there is nothing for a release gate or a notifier to guard.
39
+ const through = (cfg, execute, extra = {}) => runMiddleware(cfg.middleware, {
40
+ tool: "vydanne", version: VERSION, command: cmd, store, apply, argv, config: cfg, cwd: process.cwd(), env: process.env, ...extra,
41
+ }, execute);
42
+
36
43
  try {
37
44
  if (["version", "-v", "--version"].includes(cmd)) {
38
45
  console.log(`vydanne ${VERSION}`);
@@ -81,31 +88,37 @@ try {
81
88
  const { PlayClient } = await import("../src/play/client.mjs");
82
89
  const spec = PLAY_COMMANDS[cmd];
83
90
  const dryRun = Boolean(spec.writes) && !apply;
84
- // Play needs no request-level gate: every mutation happens inside an Edit, and an Edit that is never
85
- // committed changes nothing. So a dry run here VALIDATES for real against Google, then discards.
86
- const client = await PlayClient.create({ keyPath: cfg.google.serviceAccountKey, packageName: cfg.google.packageName, dryRun });
87
- if (dryRun) console.log(yellow(`DRY RUN — '${cmd} --store google' validates against Play and discards the edit. Add --apply to commit.`));
88
- const { run } = await import(`../src/play/commands/${spec.mod}.mjs`);
89
- const ok = await run(cfg, client);
90
- if (ok === false) process.exit(1);
91
+ // Middleware runs BEFORE the client exists, so a refusal costs no authentication and no Play request.
92
+ const result = await through(cfg, async () => {
93
+ // Play needs no request-level gate: every mutation happens inside an Edit, and an Edit that is never
94
+ // committed changes nothing. So a dry run here VALIDATES for real against Google, then discards.
95
+ const client = await PlayClient.create({ keyPath: cfg.google.serviceAccountKey, packageName: cfg.google.packageName, dryRun });
96
+ if (dryRun) console.log(yellow(`DRY RUN — '${cmd} --store google' validates against Play and discards the edit. Add --apply to commit.`));
97
+ const { run } = await import(`../src/play/commands/${spec.mod}.mjs`);
98
+ return { ok: (await run(cfg, client)) !== false, planned: [] };
99
+ }, { dryRun, writes: Boolean(spec.writes) });
100
+ if (result?.ok === false) process.exit(1);
91
101
  } else if (COMMANDS[cmd]) {
92
102
  const cfg = await loadConfig(cfgPath);
93
103
  const spec = COMMANDS[cmd];
94
104
  const { run } = await import(`../src/commands/${spec.mod}.mjs`);
95
105
  const dryRun = Boolean(spec.writes) && !apply;
96
- const client = spec.client ? new Client({ keyId: cfg.keyId, issuerId: cfg.issuerId, keyPath: cfg.keyPath, keyContent: cfg.keyContent, dryRun }) : null;
97
- if (dryRun) console.log(yellow(`DRY RUN — '${cmd}' will not change App Store Connect. Add --apply to write.`));
98
- // altool authenticates on its own rather than through our JWT, so it needs the raw ids.
99
- const ok = await run(cfg, client, spec.credentials ? { keyId: cfg.keyId, issuerId: cfg.issuerId } : undefined);
100
- // The count is the point: "nothing happened" is not the same as "nothing would happen", and only the
101
- // second one means the local state already matches the store.
102
- if (dryRun && client) {
103
- const n = client.planned.length;
104
- console.log(yellow(n
105
- ? `DRY RUN — ${n} store write(s) withheld. Re-run with --apply to perform them.`
106
- : "DRY RUN — nothing to write; the store already matches local."));
107
- }
108
- if (ok === false) process.exit(1);
106
+ const result = await through(cfg, async () => {
107
+ const client = spec.client ? new Client({ keyId: cfg.keyId, issuerId: cfg.issuerId, keyPath: cfg.keyPath, keyContent: cfg.keyContent, dryRun }) : null;
108
+ if (dryRun) console.log(yellow(`DRY RUN — '${cmd}' will not change App Store Connect. Add --apply to write.`));
109
+ // altool authenticates on its own rather than through our JWT, so it needs the raw ids.
110
+ const ok = await run(cfg, client, spec.credentials ? { keyId: cfg.keyId, issuerId: cfg.issuerId } : undefined);
111
+ // The count is the point: "nothing happened" is not the same as "nothing would happen", and only the
112
+ // second one means the local state already matches the store.
113
+ if (dryRun && client) {
114
+ const n = client.planned.length;
115
+ console.log(yellow(n
116
+ ? `DRY RUN — ${n} store write(s) withheld. Re-run with --apply to perform them.`
117
+ : "DRY RUN — nothing to write; the store already matches local."));
118
+ }
119
+ return { ok: ok !== false, planned: client?.planned ?? [] };
120
+ }, { dryRun, writes: Boolean(spec.writes) });
121
+ if (result?.ok === false) process.exit(1);
109
122
  } else {
110
123
  console.error(usage());
111
124
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vydanne",
3
- "version": "0.14.0",
3
+ "version": "1.0.0-rc.1",
4
4
  "description": "App Store Connect + Google Play submission prep — the companion to zdymak (media). Native Node (no fastlane/Ruby/Python): localized listings, screenshot/preview/icon upload, ratings, review contact, accessibility & privacy labels, IAP, export docs, build upload to TestFlight / a Play closed track, a diff of local-vs-store, and a preflight verifier that encodes the store gotchas. Never submits for review. iOS/macOS via the ASC REST API; Android via the Play Developer Edits API (--store google).",
5
5
  "keywords": [
6
6
  "app-store-connect",
@@ -52,11 +52,13 @@
52
52
  "check:docs": "node scripts/check-docs.mjs",
53
53
  "check:types": "tsc --noEmit -p tsconfig.json && node scripts/check-types.mjs",
54
54
  "check:config": "node scripts/check-config.mjs",
55
- "verify": "npm run check:docs && npm run check:types && npm run check:config",
55
+ "verify": "npm run check:docs && npm run check:types && npm run check:config && npm run check:middleware && npm run check:release",
56
56
  "prepublishOnly": "npm run verify",
57
57
  "release:patch": "node scripts/release.mjs patch",
58
58
  "release:minor": "node scripts/release.mjs minor",
59
- "release:major": "node scripts/release.mjs major"
59
+ "release:major": "node scripts/release.mjs major",
60
+ "check:middleware": "node --test scripts/middleware.test.mjs",
61
+ "check:release": "node --test scripts/release-from-tag.test.mjs"
60
62
  },
61
63
  "dependencies": {
62
64
  "dotenv": "^17.4.2",
@@ -54,10 +54,19 @@ async function versionsFor(client, platform) {
54
54
  return json.data || [];
55
55
  }
56
56
 
57
- /** The newest build, together with the marketing version its archive declares. */
58
- async function newestBuildWithVersion(client) {
57
+ /**
58
+ * The newest build FOR THIS PLATFORM, together with the marketing version its archive declares.
59
+ *
60
+ * Filtered by platform, which it was not: an app shipping on iOS and the Mac App Store uploads a build
61
+ * to each, and the unfiltered query returned whichever went up last. Preparing MAC_OS then tried to
62
+ * attach an iOS build and Apple answered 409 — so the Mac version was left holding nothing, or an older
63
+ * build, purely because the iOS one was newer. The 409 made it visible; a same-platform mix-up would
64
+ * not have been.
65
+ */
66
+ async function newestBuildWithVersion(client, platform) {
59
67
  const { json } = await client.get(
60
68
  `/v1/builds?filter[app]=${client.appId}&sort=-uploadedDate&limit=1` +
69
+ (platform ? `&filter[preReleaseVersion.platform]=${platform}` : "") +
61
70
  `&fields[builds]=version,processingState&include=preReleaseVersion` +
62
71
  `&fields[preReleaseVersions]=version`,
63
72
  );
@@ -218,7 +227,7 @@ export async function run(config, client) {
218
227
  async function prepareOne(config, client, platform) {
219
228
  console.log(green(`prepare → App Store version (${platform})`));
220
229
 
221
- const { build, marketing } = await newestBuildWithVersion(client);
230
+ const { build, marketing } = await newestBuildWithVersion(client, platform);
222
231
  const target = process.env.VYDANNE_VERSION || marketing;
223
232
 
224
233
  if (!target) {
@@ -56,7 +56,12 @@ export async function run(config, client) {
56
56
  console.log(" no versions on this app");
57
57
  return true;
58
58
  }
59
- const version = mine[0];
59
+ // Pick the version that is ACTUALLY in review, not merely the first one Apple returned. An app on two
60
+ // platforms has two 1.2 records, and `mine[0]` was whichever came back first: with iOS
61
+ // WAITING_FOR_REVIEW and macOS PREPARE_FOR_SUBMISSION, this reported "not submitted — nothing to
62
+ // withdraw" and exited 0, never looking at the submission it was asked to cancel. Silence is the worst
63
+ // possible answer here, because the operator walks away believing the withdrawal happened.
64
+ const version = mine.find((v) => WITHDRAWABLE[v.attributes.appStoreState]) || mine[0];
60
65
  const state = version.attributes.appStoreState;
61
66
  const label = `${version.attributes.versionString} (${state})`;
62
67
 
package/src/config.mjs CHANGED
@@ -5,10 +5,11 @@ import { resolveLocales } from "./locales.mjs";
5
5
  import { resolveCredentials } from "./credentials.mjs";
6
6
  import { DEFAULT_SCREENSHOT_BASE } from "./screenshots.mjs";
7
7
  import { DEFAULT_PLAY_IMAGES } from "./play/images.mjs";
8
+ import { normalizeMiddleware } from "./middleware.mjs";
8
9
 
9
10
  // The public config surface — the drift guards assert each key is documented (README/SKILL) and typed
10
11
  // (types/index.d.ts). Add a config knob → document + type it, or the guards fail before publish.
11
- export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "localeMap", "metadataDir", "screenshots", "rating", "ageRating", "categories", "contentRights", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility", "bridge", "push", "reviewContact", "allowCrossStoreTerms", "buildNumberOffset"];
12
+ export const CONFIG_KEYS = ["bundleId", "primaryLocale", "asc", "platforms", "uiLocales", "localeMap", "metadataDir", "screenshots", "rating", "ageRating", "categories", "contentRights", "privacy", "iaps", "previews", "export", "ios", "google", "accessibility", "bridge", "push", "reviewContact", "allowCrossStoreTerms", "buildNumberOffset", "middleware"];
12
13
 
13
14
  // One `vydanne.config.mjs` per app (ESM, like zdymak.config.mjs) — nothing hard-coded. Secrets stay out:
14
15
  // credentials resolve from the environment, a gitignored .env, or ~/.appstoreconnect/config.json (see
@@ -94,6 +95,9 @@ export async function loadConfig(p) {
94
95
  // tool cannot verify, so it is refused unless it is an integer and reported wherever it is
95
96
  // applied. 0 means the plain convention, which is every app that never had the accident.
96
97
  buildNumberOffset: integerOr("buildNumberOffset", raw.buildNumberOffset, 0),
98
+ // The consumer's own logic around every store command: `[(ctx, next) => …]` or `{ name, commands, stores, run }`.
99
+ // Validated HERE so a malformed entry fails at config load, not halfway through a release. See middleware.mjs.
100
+ middleware: normalizeMiddleware(raw.middleware, "vydanne"),
97
101
  // Terms the cross-store check must not flag for this app (see src/crossStore.mjs).
98
102
  allowCrossStoreTerms: raw.allowCrossStoreTerms || [],
99
103
  previews: raw.previews || null,
package/src/index.mjs CHANGED
@@ -4,13 +4,16 @@
4
4
  // filenames that nothing outside the package could resolve, because `exports` maps only ".". So a
5
5
  // consumer could see that `fill` existed and had no way to run it. `runCommand` is that missing half:
6
6
  // the same dispatch bin/ performs, minus the argv parsing and the process.exit.
7
+ import { readFileSync } from "node:fs";
7
8
  import { Client } from "./client.mjs";
8
9
  import { loadConfig } from "./config.mjs";
10
+ import { runMiddleware } from "./middleware.mjs";
9
11
  import { COMMANDS, PLAY_COMMANDS } from "./registry.mjs";
10
12
 
11
13
  export { Client } from "./client.mjs";
12
14
  export { PlayClient } from "./play/client.mjs";
13
15
  export { loadConfig, CONFIG_KEYS } from "./config.mjs";
16
+ export { MiddlewareRefusal, normalizeMiddleware, runMiddleware } from "./middleware.mjs";
14
17
  export { COMMANDS, PLAY_COMMANDS, COMMAND_NAMES } from "./registry.mjs";
15
18
  export { resolveLocales, toAsc, VALID, UI_TO_ASC } from "./locales.mjs";
16
19
  export { makeToken, resolveKey } from "./jwt.mjs";
@@ -34,6 +37,7 @@ export { DEFAULT_PLAY_IMAGES, PLAY_IMAGE_KIND, playImages } from "./play/images.
34
37
  * @param {string} [opts.configPath] path to vydanne.config.mjs, when loading from disk
35
38
  * @param {"apple"|"google"} [opts.store]
36
39
  * @param {boolean} [opts.apply] perform store writes (default false — dry run)
40
+ * @param {string[]} [opts.argv] the command-line arguments to show middleware (default: none)
37
41
  */
38
42
  export async function runCommand(name, opts = {}) {
39
43
  const { store = "apple", apply = false, configPath } = opts;
@@ -49,15 +53,27 @@ export async function runCommand(name, opts = {}) {
49
53
  if (store === "google") {
50
54
  if (!config.google) throw new Error("vydanne: no `google` block in config — add packageName + a service-account key");
51
55
  if (!config.google.serviceAccountKey) throw new Error("vydanne: set PLAY_JSON_KEY_FILE (or google.serviceAccountKey) to the Play service-account JSON");
52
- const { PlayClient } = await import("./play/client.mjs");
53
- const client = await PlayClient.create({ keyPath: config.google.serviceAccountKey, packageName: config.google.packageName, dryRun });
54
- const { run } = await import(`./play/commands/${spec.mod}.mjs`);
55
- return { ok: (await run(config, client)) !== false, planned: [] };
56
56
  }
57
57
 
58
- const client = spec.client ? new Client({ keyId: config.keyId, issuerId: config.issuerId, keyPath: config.keyPath, keyContent: config.keyContent, dryRun }) : null;
59
- const { run } = await import(`./commands/${spec.mod}.mjs`);
60
- // altool authenticates on its own rather than through our JWT, so it needs the raw ids.
61
- const ok = await run(config, client, spec.credentials ? { keyId: config.keyId, issuerId: config.issuerId } : undefined);
62
- return { ok: ok !== false, planned: client?.planned ?? [] };
58
+ // The same `middleware` the CLI runs, with the same context, so a gate written for one holds for the other.
59
+ // `argv` is empty here: a library caller has no command line.
60
+ const ctx = {
61
+ tool: "vydanne", version: VERSION, command: name, store, apply, dryRun, writes: Boolean(spec.writes),
62
+ argv: opts.argv ?? [], config, cwd: process.cwd(), env: process.env,
63
+ };
64
+ return runMiddleware(config.middleware, ctx, async () => {
65
+ if (store === "google") {
66
+ const { PlayClient } = await import("./play/client.mjs");
67
+ const client = await PlayClient.create({ keyPath: config.google.serviceAccountKey, packageName: config.google.packageName, dryRun });
68
+ const { run } = await import(`./play/commands/${spec.mod}.mjs`);
69
+ return { ok: (await run(config, client)) !== false, planned: [] };
70
+ }
71
+ const client = spec.client ? new Client({ keyId: config.keyId, issuerId: config.issuerId, keyPath: config.keyPath, keyContent: config.keyContent, dryRun }) : null;
72
+ const { run } = await import(`./commands/${spec.mod}.mjs`);
73
+ // altool authenticates on its own rather than through our JWT, so it needs the raw ids.
74
+ const ok = await run(config, client, spec.credentials ? { keyId: config.keyId, issuerId: config.issuerId } : undefined);
75
+ return { ok: ok !== false, planned: client?.planned ?? [] };
76
+ });
63
77
  }
78
+
79
+ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Middleware: a consumer's own logic around every command.
3
+ *
4
+ * THIS FILE IS IDENTICAL IN vydanne AND zdymak. Both tools take the same `middleware` config key and give
5
+ * it the same semantics, so a check written for one reads the same in the other. Change both together.
6
+ *
7
+ * // vydanne.config.mjs or zdymak.config.mjs
8
+ * export default {
9
+ * middleware: [
10
+ * // a function: runs for every command
11
+ * async (ctx, next) => {
12
+ * console.log(`about to run ${ctx.command}`);
13
+ * const result = await next(); // the rest of the chain, then the command itself
14
+ * console.log('done');
15
+ * return result; // return nothing and the command's own result is kept
16
+ * },
17
+ * // an object: the same function, limited to the commands (and stores) it names
18
+ * {
19
+ * name: 'release-gate',
20
+ * commands: ['prerelease'],
21
+ * stores: ['google'], // vydanne only
22
+ * run(ctx, next) {
23
+ * if (ctx.apply && !receiptFor(ctx)) ctx.fail('no receipt for this bundle');
24
+ * return next();
25
+ * },
26
+ * },
27
+ * ],
28
+ * };
29
+ *
30
+ * Order is the array order: the first entry is the outermost. Code before `await next()` runs on the way in and
31
+ * may REFUSE (`ctx.fail(message)` stops the command and exits non-zero); code after it runs on the way out and
32
+ * sees the command's result. An entry that never calls `next()` skips the command, and says so. `ctx` carries
33
+ * what the tool knows (see each tool's docs); `ctx.config` is the live config, so a change made on the way in
34
+ * is seen by the command.
35
+ *
36
+ * What middleware is NOT: a plugin system for new commands, and not a sandbox. It runs with the full power of
37
+ * the config file it lives in, which is already arbitrary code.
38
+ */
39
+
40
+ /** Thrown by `ctx.fail`. The CLIs print it as a refusal and exit 1, with no stack trace. */
41
+ export class MiddlewareRefusal extends Error {
42
+ constructor(reason, middleware) {
43
+ super(`refused by middleware '${middleware}': ${reason}`);
44
+ this.name = 'MiddlewareRefusal';
45
+ this.middleware = middleware;
46
+ this.reason = reason;
47
+ }
48
+ }
49
+
50
+ function names(value, label, where) {
51
+ if (value == null) return null;
52
+ const list = Array.isArray(value) ? value : [value];
53
+ if (!list.every((x) => typeof x === 'string' && x)) {
54
+ throw new Error(`${where}: '${label}' must be a string or an array of strings`);
55
+ }
56
+ return list;
57
+ }
58
+
59
+ /**
60
+ * Validate the `middleware` config value and return `{ name, commands, stores, run }` entries.
61
+ * A bad entry is a config error at load time, never a surprise halfway through a release.
62
+ */
63
+ export function normalizeMiddleware(list, tool = 'tool') {
64
+ if (list == null) return [];
65
+ if (!Array.isArray(list)) {
66
+ throw new Error(`${tool}: config 'middleware' must be an array of functions or { run } objects, got ${typeof list}`);
67
+ }
68
+ return list.map((entry, i) => {
69
+ const where = `${tool}: config 'middleware'[${i}]`;
70
+ const fallback = `middleware[${i}]`;
71
+ if (typeof entry === 'function') return { name: entry.name || fallback, commands: null, stores: null, run: entry };
72
+ if (entry && typeof entry === 'object' && typeof entry.run === 'function') {
73
+ return {
74
+ name: String(entry.name ?? (entry.run.name || fallback)),
75
+ commands: names(entry.commands, 'commands', where),
76
+ stores: names(entry.stores, 'stores', where),
77
+ run: entry.run,
78
+ };
79
+ }
80
+ throw new Error(`${where} must be a function (ctx, next) or an object with a run(ctx, next) function`);
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Run `final` (the command) inside the middleware chain and return its result.
86
+ *
87
+ * `ctx` is the tool's context object; `log`, `warn` and `fail` are added to it here, so an entry can say
88
+ * `ctx.fail('...')` without importing anything.
89
+ */
90
+ export async function runMiddleware(list, ctx, final) {
91
+ const chain = normalizeMiddleware(list, ctx.tool);
92
+ if (chain.length === 0) return final();
93
+
94
+ const current = { name: 'middleware' };
95
+ ctx.log = (...args) => console.log(`[${current.name}]`, ...args);
96
+ ctx.warn = (...args) => console.warn(`[${current.name}]`, ...args);
97
+ ctx.fail = (reason) => {
98
+ throw new MiddlewareRefusal(reason, current.name);
99
+ };
100
+
101
+ const dispatch = async (i) => {
102
+ if (i === chain.length) return final();
103
+ const mw = chain[i];
104
+ if (mw.commands && !mw.commands.includes(ctx.command)) return dispatch(i + 1);
105
+ if (mw.stores && ctx.store && !mw.stores.includes(ctx.store)) return dispatch(i + 1);
106
+
107
+ let called = false;
108
+ let inner;
109
+ const next = async () => {
110
+ if (called) throw new Error(`middleware '${mw.name}' called next() twice`);
111
+ called = true;
112
+ inner = await dispatch(i + 1);
113
+ current.name = mw.name; // control is back with this entry, so its messages carry its name again
114
+ return inner;
115
+ };
116
+
117
+ current.name = mw.name;
118
+ const result = await mw.run(ctx, next);
119
+ if (!called) console.log(` (${ctx.command} skipped: middleware '${mw.name}' did not call next())`);
120
+ // An entry that only guards on the way in has nothing to return; the command's own result is kept.
121
+ return result === undefined ? inner : result;
122
+ };
123
+
124
+ return dispatch(0);
125
+ }
package/types/index.d.ts CHANGED
@@ -355,8 +355,62 @@ export interface VydanneConfig {
355
355
  * a wrong commit as confidently as a right one.
356
356
  */
357
357
  buildNumberOffset?: number;
358
+ /**
359
+ * The app's own logic around every store command, in the order given (the first is outermost). Each entry
360
+ * is a function `(ctx, next)` or an object `{ name, commands, stores, run }` that limits it. Code before
361
+ * `await next()` runs first and may refuse with `ctx.fail(reason)`; code after it sees the command's
362
+ * result. Not run for `auth`, `locales`, `version` or help. See the README, "Middleware".
363
+ */
364
+ middleware?: Middleware[];
365
+ }
366
+
367
+ /** What a middleware entry is handed. `config` is live: a change made before `next()` is seen by the command. */
368
+ export interface MiddlewareContext {
369
+ tool: "vydanne";
370
+ version: string;
371
+ /** The command, e.g. `prerelease`. */
372
+ command: string;
373
+ store: Store;
374
+ /** `--apply` (or VYDANNE_COMMIT=1) was given: the command will write to the store. */
375
+ apply: boolean;
376
+ /** The command writes and `apply` is off, so it will only validate. */
377
+ dryRun: boolean;
378
+ /** The command is one that can write to a store at all. */
379
+ writes: boolean;
380
+ /** The command-line arguments after the command name; empty when run through `runCommand`. */
381
+ argv: string[];
382
+ config: ResolvedConfig;
383
+ cwd: string;
384
+ env: Record<string, string | undefined>;
385
+ log(...args: unknown[]): void;
386
+ warn(...args: unknown[]): void;
387
+ /** Refuse: stops the command, prints the reason and exits 1. */
388
+ fail(reason: string): never;
389
+ }
390
+
391
+ export type MiddlewareFunction = (ctx: MiddlewareContext, next: () => Promise<unknown>) => unknown;
392
+
393
+ export interface MiddlewareObject {
394
+ /** Shown in messages; defaults to the function's own name. */
395
+ name?: string;
396
+ /** Run only for these commands. */
397
+ commands?: string | string[];
398
+ /** Run only for these stores. */
399
+ stores?: Store | Store[];
400
+ run: MiddlewareFunction;
401
+ }
402
+
403
+ export type Middleware = MiddlewareFunction | MiddlewareObject;
404
+
405
+ /** Thrown by `ctx.fail`. The CLI prints it as a refusal and exits 1. */
406
+ export declare class MiddlewareRefusal extends Error {
407
+ middleware: string;
408
+ reason: string;
358
409
  }
359
410
 
411
+ export declare function normalizeMiddleware(list: unknown, tool?: string): Array<Required<Omit<MiddlewareObject, "commands" | "stores">> & { commands: string[] | null; stores: string[] | null }>;
412
+ export declare function runMiddleware<T>(list: unknown, ctx: Record<string, unknown> & { tool: string; command: string }, final: () => Promise<T>): Promise<T>;
413
+
360
414
  /** Thin ASC REST client (native fetch + ES256 JWT). */
361
415
  export declare class Client {
362
416
  constructor(opts: { keyId: string; issuerId: string; keyPath?: string; keyContent?: string; dryRun?: boolean });
@@ -416,6 +470,8 @@ export declare function runCommand(
416
470
  configPath?: string;
417
471
  store?: Store;
418
472
  apply?: boolean;
473
+ /** Shown to middleware as `ctx.argv`. */
474
+ argv?: string[];
419
475
  },
420
476
  ): Promise<{ ok: boolean; planned: Array<{ method: string; path: string; attributes: Record<string, unknown> }> }>;
421
477