@rrlab/cli 0.0.2 → 0.0.3-git-aeeb52d.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,6 +35,26 @@ pnpm rr help
35
35
 
36
36
  See [`CLI.md`](./CLI.md) for the full reference (auto-generated per release).
37
37
 
38
+ ## Plugins
39
+
40
+ `rr` is a microkernel: every tool (Biome, TypeScript, tsdown, …) lives in its own `@rrlab/<tool>-plugin` package. Install one with:
41
+
42
+ ```sh
43
+ rr plugins add biome
44
+ ```
45
+
46
+ This installs `@rrlab/biome-plugin` plus its peer tool and shared config, scaffolds `biome.json` if missing, and wires the plugin into `run-run.config.{ts,mts}`.
47
+
48
+ To install from a specific dist-tag (e.g. a PR preview release published as `pr-<N>`, or a custom tag), append `@<spec>`:
49
+
50
+ ```sh
51
+ rr plugins add biome@pr-226 # preview tag
52
+ rr plugins add biome@next # any dist-tag
53
+ rr plugins add biome@^0.1.0 # explicit version range (sibling configs still use latest)
54
+ ```
55
+
56
+ When the spec is a dist-tag, `rr` resolves any `@rrlab/*-config` sibling at the same tag, falling back to `latest` if the registry doesn't have the sibling at that tag.
57
+
38
58
  ## Shell completion
39
59
 
40
60
  `rr` ships a `completion` subcommand that prints a shell-specific script.
@@ -1,7 +1,7 @@
1
1
  // @generated by @usage-spec/commander from Commander.js metadata
2
2
  name rr
3
3
  bin rr
4
- version "0.0.2"
4
+ version "0.0.3-git-aeeb52d.0"
5
5
  usage "<command...> [options...]"
6
6
  flag --usage help="print KDL spec for this CLI (https://kdl.dev)"
7
7
  cmd completion help="print shell completion script (usage)" {
@@ -49,9 +49,7 @@ cmd plugins subcommand_required=#true help="manage @rrlab plugins" {
49
49
  flag --force help="re-run install even if the plugin is already configured"
50
50
  flag --yes help="skip prompts and use defaults (non-interactive)"
51
51
  flag --dry-run help="show what would happen, without applying changes"
52
- arg <name> help="plugin alias" {
53
- choices ts eslint biome oxc tsdown
54
- }
52
+ arg <name> help="plugin alias (ts|eslint|biome|oxc|tsdown), optionally with @<spec> e.g. biome@pr-226"
55
53
  }
56
54
  cmd remove help="uninstall an @rrlab plugin and undo its config files + deps" {
57
55
  flag --yes help="skip the confirmation prompt"
package/dist/config.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { u as Plugin } from "./types-C3V27_kd.mjs";
1
+ import { u as Plugin } from "./types-BPHxibPr.mjs";
2
2
 
3
3
  //#region src/types/config.d.ts
4
4
  type UserConfig = {
package/dist/plugin.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as Linter, D as TypeChecker, E as TypeCheckOptions, S as LintOptions, T as StaticCheckerOptions, _ as Doctor, a as InstallFlags, b as FormatOptions, c as PLUGIN_KINDS, d as PluginCapabilities, f as PluginContext, g as UninstallResult, h as UninstallFlags, i as InstallContext, l as Packer, m as UninstallContext, n as ClackPromptsSelectOption, o as InstallResult, p as PluginKind, r as FileOp, s as JsonEdit, t as ClackPrompts, u as Plugin, v as DoctorOutput, w as StaticChecker, x as Formatter, y as DoctorResult } from "./types-C3V27_kd.mjs";
1
+ import { C as Linter, D as TypeChecker, E as TypeCheckOptions, O as ReleaseService, S as LintOptions, T as StaticCheckerOptions, _ as Doctor, a as InstallFlags, b as FormatOptions, c as PLUGIN_KINDS, d as PluginCapabilities, f as PluginContext, g as UninstallResult, h as UninstallFlags, i as InstallContext, k as ReleaseServiceOptions, l as Packer, m as UninstallContext, n as ClackPromptsSelectOption, o as InstallResult, p as PluginKind, r as FileOp, s as JsonEdit, t as ClackPrompts, u as Plugin, v as DoctorOutput, w as StaticChecker, x as Formatter, y as DoctorResult } from "./types-BPHxibPr.mjs";
2
2
  import { ShellService } from "@vlandoss/clibuddy";
3
3
 
4
4
  //#region src/plugin/define-plugin.d.ts
@@ -49,4 +49,4 @@ declare class ToolService {
49
49
  doctor(): Promise<DoctorResult>;
50
50
  }
51
51
  //#endregion
52
- export { type ClackPrompts, type ClackPromptsSelectOption, type Doctor, type DoctorOutput, type DoctorResult, type FileOp, type FormatOptions, type Formatter, type InstallContext, type InstallFlags, type InstallResult, type JsonEdit, type LintOptions, type Linter, PLUGIN_KINDS, type Packer, type Plugin, type PluginCapabilities, type PluginContext, type PluginKind, type StaticChecker, type StaticCheckerOptions, ToolService, type ToolServiceOptions, type TypeCheckOptions, type TypeChecker, type UninstallContext, type UninstallFlags, type UninstallResult, definePlugin };
52
+ export { type ClackPrompts, type ClackPromptsSelectOption, type Doctor, type DoctorOutput, type DoctorResult, type FileOp, type FormatOptions, type Formatter, type InstallContext, type InstallFlags, type InstallResult, type JsonEdit, type LintOptions, type Linter, PLUGIN_KINDS, type Packer, type Plugin, type PluginCapabilities, type PluginContext, type PluginKind, ReleaseService, type ReleaseServiceOptions, type StaticChecker, type StaticCheckerOptions, ToolService, type ToolServiceOptions, type TypeCheckOptions, type TypeChecker, type UninstallContext, type UninstallFlags, type UninstallResult, definePlugin };
package/dist/plugin.mjs CHANGED
@@ -62,4 +62,49 @@ const PLUGIN_KINDS = [
62
62
  "pack"
63
63
  ];
64
64
  //#endregion
65
- export { PLUGIN_KINDS, ToolService, definePlugin };
65
+ //#region src/services/release.ts
66
+ const REGISTRY = "https://registry.npmjs.org";
67
+ const PROBE_TIMEOUT_MS = 5e3;
68
+ /**
69
+ * Represents the "release" the current `rr plugins add` runs against — the
70
+ * dist-tag the user picked (default: latest), plus the logic to resolve install
71
+ * specs for related packages under that release.
72
+ *
73
+ * - With no `tag`, `resolve()` always returns `"latest"` and never hits the
74
+ * registry.
75
+ * - With a `tag` (e.g. `"pr-226"`), probes `<pkg>@<tag>`: returns the tag when
76
+ * it exists, falls back to `"latest"` otherwise so a partial preview release
77
+ * (where only a subset of packages got published) still installs cleanly.
78
+ * - Per-package result is cached within the service instance.
79
+ */
80
+ var ReleaseService = class {
81
+ tag;
82
+ #fetcher;
83
+ #cache = /* @__PURE__ */ new Map();
84
+ constructor(tag, { fetcher = fetch } = {}) {
85
+ this.tag = tag;
86
+ this.#fetcher = fetcher;
87
+ }
88
+ resolve(pkg) {
89
+ if (!this.tag || this.tag === "latest") return Promise.resolve("latest");
90
+ const cached = this.#cache.get(pkg);
91
+ if (cached) return cached;
92
+ const promise = this.#probe(pkg);
93
+ this.#cache.set(pkg, promise);
94
+ return promise;
95
+ }
96
+ async #probe(pkg) {
97
+ const tag = this.tag;
98
+ const controller = new AbortController();
99
+ const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
100
+ try {
101
+ return (await this.#fetcher(`${REGISTRY}/${pkg}/${encodeURIComponent(tag)}`, { signal: controller.signal })).ok ? tag : "latest";
102
+ } catch {
103
+ return "latest";
104
+ } finally {
105
+ clearTimeout(timeout);
106
+ }
107
+ }
108
+ };
109
+ //#endregion
110
+ export { PLUGIN_KINDS, ReleaseService, ToolService, definePlugin };
package/dist/run.mjs CHANGED
@@ -762,6 +762,51 @@ function createClackPrompts() {
762
762
  };
763
763
  }
764
764
  //#endregion
765
+ //#region src/services/release.ts
766
+ const REGISTRY = "https://registry.npmjs.org";
767
+ const PROBE_TIMEOUT_MS = 5e3;
768
+ /**
769
+ * Represents the "release" the current `rr plugins add` runs against — the
770
+ * dist-tag the user picked (default: latest), plus the logic to resolve install
771
+ * specs for related packages under that release.
772
+ *
773
+ * - With no `tag`, `resolve()` always returns `"latest"` and never hits the
774
+ * registry.
775
+ * - With a `tag` (e.g. `"pr-226"`), probes `<pkg>@<tag>`: returns the tag when
776
+ * it exists, falls back to `"latest"` otherwise so a partial preview release
777
+ * (where only a subset of packages got published) still installs cleanly.
778
+ * - Per-package result is cached within the service instance.
779
+ */
780
+ var ReleaseService = class {
781
+ tag;
782
+ #fetcher;
783
+ #cache = /* @__PURE__ */ new Map();
784
+ constructor(tag, { fetcher = fetch } = {}) {
785
+ this.tag = tag;
786
+ this.#fetcher = fetcher;
787
+ }
788
+ resolve(pkg) {
789
+ if (!this.tag || this.tag === "latest") return Promise.resolve("latest");
790
+ const cached = this.#cache.get(pkg);
791
+ if (cached) return cached;
792
+ const promise = this.#probe(pkg);
793
+ this.#cache.set(pkg, promise);
794
+ return promise;
795
+ }
796
+ async #probe(pkg) {
797
+ const tag = this.tag;
798
+ const controller = new AbortController();
799
+ const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
800
+ try {
801
+ return (await this.#fetcher(`${REGISTRY}/${pkg}/${encodeURIComponent(tag)}`, { signal: controller.signal })).ok ? tag : "latest";
802
+ } catch {
803
+ return "latest";
804
+ } finally {
805
+ clearTimeout(timeout);
806
+ }
807
+ }
808
+ };
809
+ //#endregion
765
810
  //#region src/services/workspace-target.ts
766
811
  function resolveWorkspaceChoice(appPkg, pm) {
767
812
  if (!appPkg.isMonorepo()) return { kind: "current" };
@@ -787,7 +832,7 @@ function pmNeedsRootFlag(pm) {
787
832
  function createPluginsCommand(ctx) {
788
833
  const cmd = createCommand("plugins").description("manage @rrlab plugins");
789
834
  cmd.command("list").description("list plugins configured in run-run.config.{ts,mts}").action(() => runList(ctx));
790
- cmd.command("add").description("install and configure an @rrlab plugin").addArgument(new Argument("<name>", "plugin alias").choices(officialAliases())).option("--force", "re-run install even if the plugin is already configured").option("--yes", "skip prompts and use defaults (non-interactive)").option("--dry-run", "show what would happen, without applying changes").action((name, opts) => runAdd(ctx, name, opts));
835
+ cmd.command("add").description("install and configure an @rrlab plugin").addArgument(new Argument("<name>", `plugin alias (${officialAliases().join("|")}), optionally with @<spec> e.g. biome@pr-226`)).option("--force", "re-run install even if the plugin is already configured").option("--yes", "skip prompts and use defaults (non-interactive)").option("--dry-run", "show what would happen, without applying changes").action((name, opts) => runAdd(ctx, name, opts));
791
836
  cmd.command("remove").description("uninstall an @rrlab plugin and undo its config files + deps").addArgument(new Argument("<name>", "plugin alias to remove").choices(officialAliases())).option("--yes", "skip the confirmation prompt").option("--dry-run", "print the plan without applying changes").action((name, opts) => runRemove(ctx, name, opts));
792
837
  return cmd;
793
838
  }
@@ -807,9 +852,13 @@ async function runList(ctx) {
807
852
  logger.info(`${rel}:`);
808
853
  for (const name of plugins) logger.info(` - ${name}`);
809
854
  }
810
- async function runAdd(ctx, alias, opts) {
855
+ async function runAdd(ctx, name, opts) {
856
+ const { alias, spec } = parseAliasSpec(name);
857
+ if (!(alias in OFFICIAL_PLUGINS)) throw new Error(`'${alias}' is invalid for argument 'name'. Allowed choices are ${officialAliases().join(", ")}.`);
811
858
  const { pkg: pkgName, exportName } = OFFICIAL_PLUGINS[alias];
812
- clack.intro(` rr plugins add ${alias} `);
859
+ const tag = spec && isDistTag(spec) ? spec : void 0;
860
+ const installSpec = spec ? `${pkgName}@${spec}` : pkgName;
861
+ clack.intro(` rr plugins add ${name} `);
813
862
  const inPkg = hasInPackageJson(ctx, pkgName);
814
863
  const ast = new ConfigAstService();
815
864
  const loaded = await ast.load(ctx.appPkg.dirPath);
@@ -824,7 +873,7 @@ async function runAdd(ctx, alias, opts) {
824
873
  const workspace = toNypmWorkspace(wsChoice);
825
874
  const targetLabel = describeWorkspaceChoice(wsChoice);
826
875
  if (opts.dryRun) {
827
- clack.log.info(`Would: install ${pkgName} as a devDependency in ${targetLabel}${inPkg ? " (already present, skipped)" : ""}.`);
876
+ clack.log.info(`Would: install ${installSpec} as a devDependency in ${targetLabel}${inPkg ? " (already present, skipped)" : ""}.`);
828
877
  if (!inConfig) {
829
878
  const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
830
879
  clack.log.info(`Would: add ${exportName}() to ${rel} (plugins[]).`);
@@ -835,8 +884,8 @@ async function runAdd(ctx, alias, opts) {
835
884
  }
836
885
  let installedNow = false;
837
886
  if (!inPkg) {
838
- await withSpinner(`Installing ${pkgName}`, async () => {
839
- await addDependency([pkgName], {
887
+ await withSpinner(`Installing ${installSpec}`, async () => {
888
+ await addDependency([installSpec], {
840
889
  cwd: ctx.appPkg.dirPath,
841
890
  dev: true,
842
891
  silent: true,
@@ -860,7 +909,8 @@ async function runAdd(ctx, alias, opts) {
860
909
  force: !!opts.force,
861
910
  yes: !!opts.yes,
862
911
  nonInteractive: !!opts.yes
863
- }
912
+ },
913
+ release: new ReleaseService(tag)
864
914
  };
865
915
  installResult = await plugin.install(installCtx);
866
916
  }
@@ -969,6 +1019,18 @@ async function runRemove(ctx, alias, opts) {
969
1019
  });
970
1020
  clack.outro(`Plugin '${alias}' removed.`);
971
1021
  }
1022
+ function parseAliasSpec(input) {
1023
+ const at = input.indexOf("@");
1024
+ if (at <= 0) return { alias: input };
1025
+ return {
1026
+ alias: input.slice(0, at),
1027
+ spec: input.slice(at + 1)
1028
+ };
1029
+ }
1030
+ /** A dist-tag starts with a letter and contains only safe identifier chars. Version ranges (`^0.1`, `>=1`, `0.0.2`, `*`) don't match. */
1031
+ function isDistTag(spec) {
1032
+ return /^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(spec) && spec !== "latest";
1033
+ }
972
1034
  function hasInPackageJson(ctx, pkgName) {
973
1035
  const pkg = ctx.appPkg.packageJson;
974
1036
  return pkgName in {
@@ -1,6 +1,31 @@
1
1
  import { Pkg, ShellService } from "@vlandoss/clibuddy";
2
2
  import { AnyLogger } from "@vlandoss/loggy";
3
3
 
4
+ //#region src/services/release.d.ts
5
+ type ReleaseServiceOptions = {
6
+ /** Override the HTTP probe — used by tests. */fetcher?: typeof fetch;
7
+ };
8
+ /**
9
+ * Represents the "release" the current `rr plugins add` runs against — the
10
+ * dist-tag the user picked (default: latest), plus the logic to resolve install
11
+ * specs for related packages under that release.
12
+ *
13
+ * - With no `tag`, `resolve()` always returns `"latest"` and never hits the
14
+ * registry.
15
+ * - With a `tag` (e.g. `"pr-226"`), probes `<pkg>@<tag>`: returns the tag when
16
+ * it exists, falls back to `"latest"` otherwise so a partial preview release
17
+ * (where only a subset of packages got published) still installs cleanly.
18
+ * - Per-package result is cached within the service instance.
19
+ */
20
+ declare class ReleaseService {
21
+ #private;
22
+ readonly tag: string | undefined;
23
+ constructor(tag: string | undefined, {
24
+ fetcher
25
+ }?: ReleaseServiceOptions);
26
+ resolve(pkg: string): Promise<string>;
27
+ }
28
+ //#endregion
4
29
  //#region src/types/tool.d.ts
5
30
  type FormatOptions = {
6
31
  fix?: boolean;
@@ -110,6 +135,13 @@ type InstallContext = {
110
135
  appPkg: Pkg;
111
136
  prompts: ClackPrompts;
112
137
  flags: InstallFlags;
138
+ /**
139
+ * The release this install is running against — encapsulates the dist-tag the
140
+ * user picked (`rr plugins add biome@pr-226`) and resolves install specs for
141
+ * related packages under it. Use `ctx.release.resolve(pkg)` for every
142
+ * `@rrlab/*-config` sibling so previews and `latest` work uniformly.
143
+ */
144
+ release: ReleaseService;
113
145
  };
114
146
  type UninstallContext = {
115
147
  shell: ShellService;
@@ -170,4 +202,4 @@ type UninstallResult = {
170
202
  files?: FileOp[];
171
203
  };
172
204
  //#endregion
173
- export { Linter as C, TypeChecker as D, TypeCheckOptions as E, LintOptions as S, StaticCheckerOptions as T, Doctor as _, InstallFlags as a, FormatOptions as b, PLUGIN_KINDS as c, PluginCapabilities as d, PluginContext as f, UninstallResult as g, UninstallFlags as h, InstallContext as i, Packer as l, UninstallContext as m, ClackPromptsSelectOption as n, InstallResult as o, PluginKind as p, FileOp as r, JsonEdit as s, ClackPrompts as t, Plugin as u, DoctorOutput as v, StaticChecker as w, Formatter as x, DoctorResult as y };
205
+ export { Linter as C, TypeChecker as D, TypeCheckOptions as E, ReleaseService as O, LintOptions as S, StaticCheckerOptions as T, Doctor as _, InstallFlags as a, FormatOptions as b, PLUGIN_KINDS as c, PluginCapabilities as d, PluginContext as f, UninstallResult as g, UninstallFlags as h, InstallContext as i, ReleaseServiceOptions as k, Packer as l, UninstallContext as m, ClackPromptsSelectOption as n, InstallResult as o, PluginKind as p, FileOp as r, JsonEdit as s, ClackPrompts as t, Plugin as u, DoctorOutput as v, StaticChecker as w, Formatter as x, DoctorResult as y };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rrlab/cli",
3
- "version": "0.0.2",
3
+ "version": "0.0.3-git-aeeb52d.0",
4
4
  "description": "The CLI toolbox to fullstack common scripts in Variable Land",
5
5
  "homepage": "https://github.com/variableland/dx/tree/main/run-run/cli#readme",
6
6
  "bugs": {
package/src/lib/plugin.ts CHANGED
@@ -29,3 +29,4 @@ export type {
29
29
  UninstallResult,
30
30
  } from "#src/plugin/types.ts";
31
31
  export { PLUGIN_KINDS } from "#src/plugin/types.ts";
32
+ export { ReleaseService, type ReleaseServiceOptions } from "#src/services/release.ts";
@@ -1,5 +1,6 @@
1
1
  import type { Pkg, ShellService } from "@vlandoss/clibuddy";
2
2
  import type { AnyLogger as Logger } from "@vlandoss/loggy";
3
+ import type { ReleaseService } from "#src/services/release.ts";
3
4
  import type { Doctor, Formatter, Linter, StaticChecker, TypeChecker } from "#src/types/tool.ts";
4
5
 
5
6
  export type {
@@ -83,6 +84,13 @@ export type InstallContext = {
83
84
  appPkg: Pkg;
84
85
  prompts: ClackPrompts;
85
86
  flags: InstallFlags;
87
+ /**
88
+ * The release this install is running against — encapsulates the dist-tag the
89
+ * user picked (`rr plugins add biome@pr-226`) and resolves install specs for
90
+ * related packages under it. Use `ctx.release.resolve(pkg)` for every
91
+ * `@rrlab/*-config` sibling so previews and `latest` work uniformly.
92
+ */
93
+ release: ReleaseService;
86
94
  };
87
95
 
88
96
  export type UninstallContext = {
@@ -10,6 +10,7 @@ import { applyJsonEdits } from "#src/services/json-edit.ts";
10
10
  import { logger } from "#src/services/logger.ts";
11
11
  import { OFFICIAL_PLUGINS, type OfficialAlias, officialAliases } from "#src/services/plugins-registry.ts";
12
12
  import { createClackPrompts } from "#src/services/prompts.ts";
13
+ import { ReleaseService } from "#src/services/release.ts";
13
14
  import { describeWorkspaceChoice, resolveWorkspaceChoice, toNypmWorkspace } from "#src/services/workspace-target.ts";
14
15
 
15
16
  type AddOptions = {
@@ -38,11 +39,13 @@ export function createPluginsCommand(ctx: Context) {
38
39
  cmd
39
40
  .command("add")
40
41
  .description("install and configure an @rrlab plugin")
41
- .addArgument(new Argument("<name>", "plugin alias").choices(officialAliases()))
42
+ .addArgument(
43
+ new Argument("<name>", `plugin alias (${officialAliases().join("|")}), optionally with @<spec> e.g. biome@pr-226`),
44
+ )
42
45
  .option("--force", "re-run install even if the plugin is already configured")
43
46
  .option("--yes", "skip prompts and use defaults (non-interactive)")
44
47
  .option("--dry-run", "show what would happen, without applying changes")
45
- .action((name: OfficialAlias, opts: AddOptions) => runAdd(ctx, name, opts));
48
+ .action((name: string, opts: AddOptions) => runAdd(ctx, name, opts));
46
49
 
47
50
  cmd
48
51
  .command("remove")
@@ -74,10 +77,16 @@ async function runList(ctx: Context) {
74
77
  }
75
78
  }
76
79
 
77
- async function runAdd(ctx: Context, alias: OfficialAlias, opts: AddOptions) {
78
- const { pkg: pkgName, exportName } = OFFICIAL_PLUGINS[alias];
80
+ async function runAdd(ctx: Context, name: string, opts: AddOptions) {
81
+ const { alias, spec } = parseAliasSpec(name);
82
+ if (!(alias in OFFICIAL_PLUGINS)) {
83
+ throw new Error(`'${alias}' is invalid for argument 'name'. Allowed choices are ${officialAliases().join(", ")}.`);
84
+ }
85
+ const { pkg: pkgName, exportName } = OFFICIAL_PLUGINS[alias as OfficialAlias];
86
+ const tag = spec && isDistTag(spec) ? spec : undefined;
87
+ const installSpec = spec ? `${pkgName}@${spec}` : pkgName;
79
88
 
80
- clack.intro(` rr plugins add ${alias} `);
89
+ clack.intro(` rr plugins add ${name} `);
81
90
 
82
91
  const inPkg = hasInPackageJson(ctx, pkgName);
83
92
  const ast = new ConfigAstService();
@@ -97,7 +106,7 @@ async function runAdd(ctx: Context, alias: OfficialAlias, opts: AddOptions) {
97
106
 
98
107
  if (opts.dryRun) {
99
108
  clack.log.info(
100
- `Would: install ${pkgName} as a devDependency in ${targetLabel}${inPkg ? " (already present, skipped)" : ""}.`,
109
+ `Would: install ${installSpec} as a devDependency in ${targetLabel}${inPkg ? " (already present, skipped)" : ""}.`,
101
110
  );
102
111
  if (!inConfig) {
103
112
  const rel = path.relative(ctx.appPkg.dirPath, loaded.filepath) || loaded.filepath;
@@ -110,8 +119,8 @@ async function runAdd(ctx: Context, alias: OfficialAlias, opts: AddOptions) {
110
119
 
111
120
  let installedNow = false;
112
121
  if (!inPkg) {
113
- await withSpinner(`Installing ${pkgName}`, async () => {
114
- await addDependency([pkgName], { cwd: ctx.appPkg.dirPath, dev: true, silent: true, workspace });
122
+ await withSpinner(`Installing ${installSpec}`, async () => {
123
+ await addDependency([installSpec], { cwd: ctx.appPkg.dirPath, dev: true, silent: true, workspace });
115
124
  });
116
125
  installedNow = true;
117
126
  }
@@ -135,6 +144,7 @@ async function runAdd(ctx: Context, alias: OfficialAlias, opts: AddOptions) {
135
144
  yes: !!opts.yes,
136
145
  nonInteractive: !!opts.yes,
137
146
  },
147
+ release: new ReleaseService(tag),
138
148
  };
139
149
  installResult = await plugin.install(installCtx);
140
150
  }
@@ -260,6 +270,17 @@ async function runRemove(ctx: Context, alias: OfficialAlias, opts: RemoveOptions
260
270
  clack.outro(`Plugin '${alias}' removed.`);
261
271
  }
262
272
 
273
+ function parseAliasSpec(input: string): { alias: string; spec?: string } {
274
+ const at = input.indexOf("@");
275
+ if (at <= 0) return { alias: input };
276
+ return { alias: input.slice(0, at), spec: input.slice(at + 1) };
277
+ }
278
+
279
+ /** A dist-tag starts with a letter and contains only safe identifier chars. Version ranges (`^0.1`, `>=1`, `0.0.2`, `*`) don't match. */
280
+ function isDistTag(spec: string): boolean {
281
+ return /^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(spec) && spec !== "latest";
282
+ }
283
+
263
284
  function hasInPackageJson(ctx: Context, pkgName: string): boolean {
264
285
  const pkg = ctx.appPkg.packageJson;
265
286
  const deps = {
@@ -0,0 +1,53 @@
1
+ const REGISTRY = "https://registry.npmjs.org";
2
+ const PROBE_TIMEOUT_MS = 5_000;
3
+
4
+ export type ReleaseServiceOptions = {
5
+ /** Override the HTTP probe — used by tests. */
6
+ fetcher?: typeof fetch;
7
+ };
8
+
9
+ /**
10
+ * Represents the "release" the current `rr plugins add` runs against — the
11
+ * dist-tag the user picked (default: latest), plus the logic to resolve install
12
+ * specs for related packages under that release.
13
+ *
14
+ * - With no `tag`, `resolve()` always returns `"latest"` and never hits the
15
+ * registry.
16
+ * - With a `tag` (e.g. `"pr-226"`), probes `<pkg>@<tag>`: returns the tag when
17
+ * it exists, falls back to `"latest"` otherwise so a partial preview release
18
+ * (where only a subset of packages got published) still installs cleanly.
19
+ * - Per-package result is cached within the service instance.
20
+ */
21
+ export class ReleaseService {
22
+ readonly tag: string | undefined;
23
+ readonly #fetcher: typeof fetch;
24
+ readonly #cache = new Map<string, Promise<string>>();
25
+
26
+ constructor(tag: string | undefined, { fetcher = fetch }: ReleaseServiceOptions = {}) {
27
+ this.tag = tag;
28
+ this.#fetcher = fetcher;
29
+ }
30
+
31
+ resolve(pkg: string): Promise<string> {
32
+ if (!this.tag || this.tag === "latest") return Promise.resolve("latest");
33
+ const cached = this.#cache.get(pkg);
34
+ if (cached) return cached;
35
+ const promise = this.#probe(pkg);
36
+ this.#cache.set(pkg, promise);
37
+ return promise;
38
+ }
39
+
40
+ async #probe(pkg: string): Promise<string> {
41
+ const tag = this.tag as string;
42
+ const controller = new AbortController();
43
+ const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
44
+ try {
45
+ const res = await this.#fetcher(`${REGISTRY}/${pkg}/${encodeURIComponent(tag)}`, { signal: controller.signal });
46
+ return res.ok ? tag : "latest";
47
+ } catch {
48
+ return "latest";
49
+ } finally {
50
+ clearTimeout(timeout);
51
+ }
52
+ }
53
+ }