@redis/redisctl 0.0.0-stage → 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 (3) hide show
  1. package/README.md +66 -2
  2. package/bin/redisctl.js +187 -0
  3. package/package.json +21 -4
package/README.md CHANGED
@@ -1,3 +1,67 @@
1
- # Temporary Holding Version
1
+ # @redis/redisctl
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Onboard a project to Redis services and make its AI coding agent Redis-fluent:
4
+
5
+ ```bash
6
+ npx @redis/redisctl init
7
+ ```
8
+
9
+ This package is a thin wrapper over [`redisctl init`](https://github.com/redis/redisctl):
10
+ it maps npm muscle memory (`-y`/`--yes` after `init`) to `redisctl`'s `--defaults`, inherits the
11
+ terminal (the wizard and banner work), and forwards exit codes verbatim
12
+ (0 success / 1 failure / 2 usage / 6 invalid input / 10 connection / 12 cancelled). Everything after
13
+ `npx @redis/redisctl` goes to `redisctl` unchanged, so other subcommands work too.
14
+
15
+ Note the position of `-y`: `npx @redis/redisctl init -y` reaches the wrapper (and
16
+ becomes `--defaults`); `npx -y @redis/redisctl init` is npx's own skip-prompt flag
17
+ and never reaches it. Both are fine - they just answer different questions.
18
+
19
+ ## Which redisctl runs
20
+
21
+ The wrapper bundles no binary. It runs the first `redisctl` it finds on PATH, then
22
+ in its own install folder, then in `~/.cargo/bin` (the official installer's
23
+ default), skipping its own npm bin entry. For `init`, that copy must have the
24
+ `init` command; an older one is skipped and left where it is.
25
+
26
+ With no usable copy, the wrapper downloads the official installer of the redisctl
27
+ release matching its own version, from GitHub releases, and runs it in unmanaged
28
+ mode: the installer checks the archive's sha256 (where `sha256sum` exists) and puts
29
+ the binary in the wrapper's folder, without editing PATH, shell profiles or the
30
+ Windows registry:
31
+
32
+ - Linux and macOS: `$XDG_DATA_HOME/redisctl/bin` (`~/.local/share/redisctl/bin`)
33
+ - Windows: `%LOCALAPPDATA%\redisctl\bin`
34
+
35
+ The next run finds that copy, and a wrapper of another version replaces it with its
36
+ own release; add the folder to PATH to run `redisctl` directly. A copy you installed
37
+ yourself is never replaced. The installer's output goes to stderr, so stdout stays
38
+ redisctl's (`-o json`). `REDISCTL_RELEASES_URL` replaces
39
+ `https://github.com/redis/redisctl/releases/download` for a mirror of the releases.
40
+
41
+ The installer needs `curl` or `wget` and, on Linux and macOS, a `tar` that reads
42
+ `.tar.xz`. Releases cover macOS (arm64, x64), Linux x64 and Windows x64.
43
+
44
+ If the install fails, the wrapper prints the other ways to install and exits 1:
45
+
46
+ ```bash
47
+ brew install redis/homebrew-tap/redisctl
48
+ cargo install redisctl
49
+ # or a binary from https://github.com/redis/redisctl/releases
50
+ ```
51
+
52
+ It never runs redisctl through a shell, so pasted connection URLs with `&`, `|`,
53
+ `^` or spaces stay single arguments on every platform.
54
+
55
+ ## Publishing checklist
56
+
57
+ 1. Publish from the release workflow, after the GitHub release exists, with this
58
+ package's `version` set to the release version: the wrapper downloads the
59
+ installer of the release with its own version.
60
+ 2. Include the repository LICENSE files in the tarball (`files` ships `bin/` only).
61
+ 3. `npm publish --tag alpha` for a dress rehearsal, then promote to `latest`.
62
+
63
+ Publish only a version whose release has the `init` command.
64
+
65
+ Test the wrapper itself with `npm pack` +
66
+ `npx --yes --package=./redis-redisctl-0.0.0.tgz redisctl init --dry-run` against
67
+ a `redisctl` on PATH (that `--yes` belongs to npx, not the wrapper).
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ // `npx @redis/redisctl init` -> `redisctl init`. The wrapper owns npm muscle
3
+ // memory (-y/--yes after `init` -> --defaults) and installs redisctl when no
4
+ // usable copy exists; redisctl owns everything else. Exit codes forward verbatim.
5
+ 'use strict';
6
+
7
+ const { spawnSync } = require('node:child_process');
8
+ const fs = require('node:fs');
9
+ const os = require('node:os');
10
+ const path = require('node:path');
11
+ const { version } = require('../package.json');
12
+
13
+ const INSTALL_HINT = `Install redisctl another way, then re-run:
14
+ brew install redis/homebrew-tap/redisctl
15
+ cargo install redisctl
16
+ https://github.com/redis/redisctl/releases`;
17
+
18
+ // redisctl's global flags that take a value, so `-p init` is not read as the
19
+ // subcommand.
20
+ const VALUE_FLAGS = new Set(['--profile', '--config-file', '--output', '--query']);
21
+ const VALUE_SHORTS = new Set(['p', 'o', 'q']);
22
+
23
+ function subcommandIndex(argv) {
24
+ for (let i = 0; i < argv.length; i++) {
25
+ const arg = argv[i];
26
+ if (arg === '--') return i + 1;
27
+ if (arg.startsWith('--')) {
28
+ if (VALUE_FLAGS.has(arg)) i++;
29
+ } else if (arg.startsWith('-') && arg.length > 1) {
30
+ // In a short cluster (-vp dev, -pdev) the first value flag takes the rest
31
+ // of the token, or the next token when nothing follows it.
32
+ const cluster = arg.slice(1);
33
+ if ([...cluster].findIndex((c) => VALUE_SHORTS.has(c)) === cluster.length - 1) i++;
34
+ } else {
35
+ return i;
36
+ }
37
+ }
38
+ return argv.length;
39
+ }
40
+
41
+ // -y/--yes only means "take the defaults" for init; other subcommands keep
42
+ // their argv untouched.
43
+ function mapArgs(argv) {
44
+ const sub = subcommandIndex(argv);
45
+ if (argv[sub] !== 'init') return argv;
46
+ return argv.map((arg, i) => (i > sub && (arg === '-y' || arg === '--yes') ? '--defaults' : arg));
47
+ }
48
+
49
+ const args = mapArgs(process.argv.slice(2));
50
+ // A redisctl from before init answers it with a usage error.
51
+ const needsInit = args[subcommandIndex(args)] === 'init';
52
+
53
+ // npm exec exports its flags as npm_config_* to every descendant; the package
54
+ // pinning (`--package=@redis/redisctl`) would make redisctl's own npm/npx calls
55
+ // (client install, skills add) resolve THIS package instead of their real
56
+ // target. Drop only the resolution-changing keys - user npm config such as
57
+ // registry and proxy must survive.
58
+ const env = { ...process.env };
59
+ delete env.npm_config_package;
60
+ delete env.npm_config_call;
61
+
62
+ const binaryName = process.platform === 'win32' ? 'redisctl.exe' : 'redisctl';
63
+ const wrapperPath = fs.realpathSync(__filename);
64
+ function isWrapper(file) {
65
+ if (file === wrapperPath) return true;
66
+ try {
67
+ const manifest = path.resolve(path.dirname(file), '..', 'package.json');
68
+ return JSON.parse(fs.readFileSync(manifest, 'utf8')).name === '@redis/redisctl';
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ const installDir =
75
+ process.platform === 'win32'
76
+ ? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'redisctl', 'bin')
77
+ : path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'redisctl', 'bin');
78
+ // The official installer's default folder.
79
+ const cargoBin = path.join(process.env.CARGO_HOME || path.join(os.homedir(), '.cargo'), 'bin');
80
+
81
+ // npx puts this wrapper's own bin entry first on PATH.
82
+ function candidates(dirs) {
83
+ const files = dirs.map((dir) => path.resolve(dir, binaryName));
84
+ return [...new Set(files)].filter((candidate) => {
85
+ try {
86
+ fs.accessSync(candidate, fs.constants.X_OK);
87
+ return fs.statSync(candidate).isFile() && !isWrapper(fs.realpathSync(candidate));
88
+ } catch {
89
+ return false;
90
+ }
91
+ });
92
+ }
93
+
94
+ function owned(command) {
95
+ return path.dirname(command) === installDir;
96
+ }
97
+
98
+ // The wrapper's own copy follows the wrapper's version, since nothing else updates
99
+ // it; a copy the user installed only needs init.
100
+ function usable(command) {
101
+ if (owned(command)) {
102
+ const ran = spawnSync(command, ['--version'], { encoding: 'utf8', env });
103
+ return ran.status === 0 && ran.stdout.trim() === `redisctl ${version}`;
104
+ }
105
+ return !needsInit || spawnSync(command, ['init', '--help'], { stdio: 'ignore', env }).status === 0;
106
+ }
107
+
108
+ function fail(message) {
109
+ console.error(message);
110
+ console.error(INSTALL_HINT);
111
+ process.exit(1);
112
+ }
113
+
114
+ // The release installer checks the archive's sha256 where sha256sum exists.
115
+ async function install() {
116
+ const file = process.platform === 'win32' ? 'redisctl-installer.ps1' : 'redisctl-installer.sh';
117
+ const base = process.env.REDISCTL_RELEASES_URL || 'https://github.com/redis/redisctl/releases/download';
118
+ const url = `${base}/redisctl-v${version}/${file}`;
119
+ let script;
120
+ try {
121
+ const response = await fetch(url);
122
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
123
+ script = await response.text();
124
+ } catch (e) {
125
+ fail(`could not download ${url}: ${e.cause?.message ?? e.message}`);
126
+ }
127
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'redisctl-install-'));
128
+ const saved = path.join(dir, file);
129
+ fs.writeFileSync(saved, script);
130
+ const [shell, shellArgs] =
131
+ process.platform === 'win32'
132
+ ? ['powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', saved]]
133
+ : ['sh', [saved]];
134
+ // Without this wrapper's bin dirs, which the installer would report as a
135
+ // redisctl shadowing the one it installs.
136
+ const PATH = (env.PATH || '')
137
+ .split(path.delimiter)
138
+ .filter((dir) => {
139
+ try {
140
+ return !isWrapper(fs.realpathSync(path.resolve(dir, binaryName)));
141
+ } catch {
142
+ return true;
143
+ }
144
+ })
145
+ .join(path.delimiter);
146
+ // An unmanaged install is flat and skips the installer's PATH, shell-profile and
147
+ // registry edits; the two other location settings would win over it.
148
+ const installerEnv = { ...env, PATH, REDISCTL_UNMANAGED_INSTALL: installDir };
149
+ delete installerEnv.REDISCTL_INSTALL_DIR;
150
+ delete installerEnv.CARGO_DIST_FORCE_INSTALL_DIR;
151
+ // Its progress goes to stderr: stdout belongs to redisctl (-o json).
152
+ const ran = spawnSync(shell, shellArgs, { stdio: ['ignore', 2, 2], env: installerEnv });
153
+ fs.rmSync(dir, { recursive: true, force: true });
154
+ if (ran.status !== 0) fail(`the redisctl ${version} installer failed`);
155
+ const installed = candidates([installDir]).find(usable);
156
+ if (!installed) fail(`the redisctl ${version} installer left no usable redisctl in ${installDir}`);
157
+ console.error(`redisctl ${version} installed to ${installDir} - add it to PATH to run redisctl directly`);
158
+ return installed;
159
+ }
160
+
161
+ async function main() {
162
+ const found = candidates([...(process.env.PATH || '').split(path.delimiter), installDir, cargoBin]);
163
+ let command = found.find(usable);
164
+ if (!command) {
165
+ console.error(
166
+ found.length === 0
167
+ ? `redisctl is not installed - installing redisctl ${version}`
168
+ : owned(found[0])
169
+ ? `updating ${found[0]} to redisctl ${version}`
170
+ : `${found[0]} has no init command - installing redisctl ${version}; that copy stays as it is`
171
+ );
172
+ command = await install();
173
+ }
174
+
175
+ // Never through a shell: a pasted connection URL can carry & | ^ %, which
176
+ // cmd.exe would run as syntax.
177
+ const result = spawnSync(command, args, { stdio: 'inherit', env });
178
+
179
+ if (result.error && result.error.code === 'ENOENT') fail(`${command} could not be started`);
180
+ if (result.signal) {
181
+ // Die the way the child did, so shells see the real interrupt.
182
+ process.kill(process.pid, result.signal);
183
+ }
184
+ process.exit(result.status ?? 1);
185
+ }
186
+
187
+ main().catch((e) => fail(e.message));
package/package.json CHANGED
@@ -1,6 +1,23 @@
1
1
  {
2
2
  "name": "@redis/redisctl",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.13.0",
4
+ "description": "Onboard a project to Redis services and make its AI coding agent Redis-fluent. Thin wrapper over the redisctl CLI: npx @redis/redisctl init.",
5
+ "license": "MIT OR Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/redis/redisctl",
9
+ "directory": "npm/redisctl"
10
+ },
11
+ "bin": {
12
+ "redisctl": "bin/redisctl.js"
13
+ },
14
+ "files": [
15
+ "bin"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "scripts": {
21
+ "test": "node --test"
22
+ }
23
+ }