@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.
- package/README.md +66 -2
- package/bin/redisctl.js +187 -0
- package/package.json +21 -4
package/README.md
CHANGED
|
@@ -1,3 +1,67 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @redis/redisctl
|
|
2
2
|
|
|
3
|
-
|
|
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).
|
package/bin/redisctl.js
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|