@lenne.tech/cli 1.50.6 → 1.51.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/build/commands/claude/plugins.js +29 -16
- package/build/commands/cli/create.js +11 -3
- package/build/commands/cli/rename.js +10 -2
- package/build/commands/dev/up.js +9 -0
- package/build/commands/directus/docker-setup.js +2 -2
- package/build/commands/directus/remove.js +1 -1
- package/build/commands/directus/typegen.js +31 -4
- package/build/commands/frontend/convert-mode.js +16 -0
- package/build/commands/frontend/nuxt.js +1 -1
- package/build/commands/fullstack/add-api.js +1 -2
- package/build/commands/fullstack/add-app.js +1 -1
- package/build/commands/fullstack/convert-mode.js +20 -0
- package/build/commands/fullstack/init.js +9 -6
- package/build/commands/git/clean.js +3 -2
- package/build/commands/git/create.js +7 -2
- package/build/commands/git/force-pull.js +4 -2
- package/build/commands/git/get.js +19 -6
- package/build/commands/git/rebase.js +13 -3
- package/build/commands/git/rename.js +5 -4
- package/build/commands/git/reset.js +18 -10
- package/build/commands/git/squash.js +4 -2
- package/build/commands/git/update.js +2 -1
- package/build/commands/npm/reinit.js +3 -3
- package/build/commands/server/add-property.js +10 -0
- package/build/commands/server/convert-mode.js +16 -0
- package/build/commands/server/permissions.js +10 -10
- package/build/commands/starter/chrome-extension.js +8 -2
- package/build/commands/typescript/create.js +3 -2
- package/build/extensions/api-mode.js +1 -1
- package/build/extensions/frontend-helper.js +25 -13
- package/build/extensions/git.js +25 -14
- package/build/extensions/server.js +14 -5
- package/build/extensions/template.js +8 -5
- package/build/extensions/typescript.js +4 -3
- package/build/lib/caddy.js +72 -2
- package/build/lib/claude-cli.js +17 -2
- package/build/lib/exec-args.js +117 -0
- package/build/lib/plugin-permissions.js +201 -0
- package/build/lib/plugin-utils.js +96 -36
- package/build/lib/safe-paths.js +57 -0
- package/build/lib/unknown-options.js +53 -0
- package/build/lib/workspace-integration.js +4 -2
- package/docs/commands.md +12 -0
- package/package.json +6 -6
|
@@ -10,6 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
10
10
|
};
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.TemplateHelper = void 0;
|
|
13
|
+
const exec_args_1 = require("../lib/exec-args");
|
|
13
14
|
/**
|
|
14
15
|
* Template helper functions for project scaffolding
|
|
15
16
|
* Provides reusable methods for copying, linking, and cloning templates
|
|
@@ -77,7 +78,7 @@ class TemplateHelper {
|
|
|
77
78
|
*/
|
|
78
79
|
linkLocal(source_1, dest_1) {
|
|
79
80
|
return __awaiter(this, arguments, void 0, function* (source, dest, options = {}) {
|
|
80
|
-
const { filesystem, print: { error, info },
|
|
81
|
+
const { filesystem, print: { error, info }, } = this.toolbox;
|
|
81
82
|
const opts = Object.assign({ showInfo: true, showWarning: true }, options);
|
|
82
83
|
const templatePath = filesystem.path(source);
|
|
83
84
|
if (!filesystem.isDirectory(templatePath)) {
|
|
@@ -85,7 +86,8 @@ class TemplateHelper {
|
|
|
85
86
|
return { method: 'link', path: templatePath, success: false };
|
|
86
87
|
}
|
|
87
88
|
try {
|
|
88
|
-
|
|
89
|
+
// No shell: the template path comes from a --*-link flag or lt.config (see exec-args.ts).
|
|
90
|
+
yield (0, exec_args_1.execArgs)('ln', ['-s', '--', templatePath, dest]);
|
|
89
91
|
if (opts.showInfo) {
|
|
90
92
|
info(`Symlinked to: ${templatePath}`);
|
|
91
93
|
}
|
|
@@ -110,11 +112,12 @@ class TemplateHelper {
|
|
|
110
112
|
*/
|
|
111
113
|
cloneRemote(repoUrl_1, dest_1) {
|
|
112
114
|
return __awaiter(this, arguments, void 0, function* (repoUrl, dest, options = {}) {
|
|
113
|
-
const { filesystem, print: { error, info },
|
|
115
|
+
const { filesystem, print: { error, info }, } = this.toolbox;
|
|
114
116
|
const opts = Object.assign({ removeGit: true, showInfo: true }, options);
|
|
115
|
-
const branchArg = opts.branch ? ` -b ${opts.branch}` : '';
|
|
116
117
|
try {
|
|
117
|
-
|
|
118
|
+
// Argument list, no shell: branch and URL come from CLI flags and lt.config files (see
|
|
119
|
+
// exec-args.ts). `--` keeps a URL that starts with `-` from being read as an option.
|
|
120
|
+
yield (0, exec_args_1.execArgs)('git', ['clone', ...(opts.branch ? ['-b', opts.branch] : []), '--', repoUrl, dest]);
|
|
118
121
|
if (opts.removeGit && filesystem.exists(`${dest}/.git`)) {
|
|
119
122
|
filesystem.remove(`${dest}/.git`);
|
|
120
123
|
}
|
|
@@ -45,6 +45,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
45
45
|
exports.Typescript = void 0;
|
|
46
46
|
const fs = __importStar(require("fs"));
|
|
47
47
|
const path_1 = require("path");
|
|
48
|
+
const exec_args_1 = require("../lib/exec-args");
|
|
48
49
|
/**
|
|
49
50
|
* Common helper functions
|
|
50
51
|
*/
|
|
@@ -81,7 +82,7 @@ class Typescript {
|
|
|
81
82
|
const cloneSpin = spin(`Init project ${name}`);
|
|
82
83
|
// Init gts
|
|
83
84
|
fs.mkdirSync(dir);
|
|
84
|
-
yield run(
|
|
85
|
+
yield run('npm init -y && npx gts init && npm install -D ts-node', { cwd: dir });
|
|
85
86
|
// Prepare package.json
|
|
86
87
|
const { data, path } = yield npm.getPackageJson({
|
|
87
88
|
cwd: dir,
|
|
@@ -145,14 +146,14 @@ class Typescript {
|
|
|
145
146
|
// Clone
|
|
146
147
|
const repository = 'https://github.com/chinchang/web-maker.git';
|
|
147
148
|
const cloneSpin = spin(`Cloning web-maker: ${repository}`);
|
|
148
|
-
yield
|
|
149
|
+
yield (0, exec_args_1.execArgs)('git', ['clone', '--', repository, dir]);
|
|
149
150
|
cloneSpin.succeed();
|
|
150
151
|
// Install packages
|
|
151
152
|
if (yield npm.install({ cwd: dir, showError: true })) {
|
|
152
153
|
return;
|
|
153
154
|
}
|
|
154
155
|
// Start
|
|
155
|
-
yield run(
|
|
156
|
+
yield run('npm start', { cwd: dir });
|
|
156
157
|
});
|
|
157
158
|
}
|
|
158
159
|
}
|
package/build/lib/caddy.js
CHANGED
|
@@ -12,6 +12,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
12
12
|
exports.paths = void 0;
|
|
13
13
|
exports.caddyAvailable = caddyAvailable;
|
|
14
14
|
exports.caddyDaemonRunning = caddyDaemonRunning;
|
|
15
|
+
exports.ensureLoopbackBind = ensureLoopbackBind;
|
|
16
|
+
exports.healLoopbackBind = healLoopbackBind;
|
|
15
17
|
exports.readCaddyfile = readCaddyfile;
|
|
16
18
|
exports.reloadCaddy = reloadCaddy;
|
|
17
19
|
exports.removeProjectBlock = removeProjectBlock;
|
|
@@ -45,6 +47,14 @@ const CADDYFILE_PATH = process.env.LT_DEV_CADDYFILE || (0, path_1.join)((0, os_1
|
|
|
45
47
|
/** Upstream connection cap per route — well below the 128 of macOS `somaxconn` (see `renderProjectBlock`). */
|
|
46
48
|
const UPSTREAM_MAX_CONNS = 32;
|
|
47
49
|
const HEADER = '# Managed by `lt dev`. Per-project blocks are bounded by `# >>> lt-dev:<slug> >>>` markers.';
|
|
50
|
+
/**
|
|
51
|
+
* Global options block that keeps Caddy on the loopback interfaces. Without it every site block
|
|
52
|
+
* listens on `*:443`/`*:80`, so a running stack answers anyone on the same network who sends
|
|
53
|
+
* the right Host header (`curl --resolve`), including the `lt dev test` stack with its seeded
|
|
54
|
+
* admin, whose password is in this public repository (`TEST_INITIAL_ADMIN_ENV`). `*.localhost`
|
|
55
|
+
* does not resolve on other machines anyway, so nothing legitimate is lost.
|
|
56
|
+
*/
|
|
57
|
+
const LOOPBACK_BIND = 'default_bind 127.0.0.1 [::1]';
|
|
48
58
|
/** Detect whether `caddy` is on PATH. */
|
|
49
59
|
function caddyAvailable() {
|
|
50
60
|
return __awaiter(this, void 0, void 0, function* () {
|
|
@@ -70,6 +80,63 @@ function caddyDaemonRunning() {
|
|
|
70
80
|
return (yield (0, dev_process_1.httpStatus)('http://127.0.0.1:2019/config/', 2000)) !== null;
|
|
71
81
|
});
|
|
72
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Make sure a Caddyfile binds to loopback only. Pure and idempotent.
|
|
85
|
+
*
|
|
86
|
+
* - No global options block: one is inserted after the leading comments (the global block
|
|
87
|
+
* must be the first block of a Caddyfile).
|
|
88
|
+
* - A global block without `default_bind`: the option is added inside it, the rest is kept.
|
|
89
|
+
* - A global block that already sets `default_bind`: unchanged, whatever it binds to. That is
|
|
90
|
+
* a deliberate choice of the user, not something to overwrite.
|
|
91
|
+
* @param content - Caddyfile content
|
|
92
|
+
* @returns Content with a loopback `default_bind`
|
|
93
|
+
*/
|
|
94
|
+
function ensureLoopbackBind(content) {
|
|
95
|
+
const lines = content.split('\n');
|
|
96
|
+
const first = lines.findIndex((line) => line.trim() !== '' && !line.trim().startsWith('#'));
|
|
97
|
+
if (first !== -1 && lines[first].trim() === '{') {
|
|
98
|
+
let depth = 0;
|
|
99
|
+
let end = lines.length - 1;
|
|
100
|
+
for (let i = first; i < lines.length; i++) {
|
|
101
|
+
const code = lines[i].replace(/#.*$/, '');
|
|
102
|
+
depth += (code.match(/\{/g) || []).length - (code.match(/\}/g) || []).length;
|
|
103
|
+
if (depth === 0) {
|
|
104
|
+
end = i;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (lines.slice(first + 1, end).some((line) => /^\s*default_bind\b/.test(line))) {
|
|
109
|
+
return content;
|
|
110
|
+
}
|
|
111
|
+
return [...lines.slice(0, first + 1), `\t${LOOPBACK_BIND}`, ...lines.slice(first + 1)].join('\n');
|
|
112
|
+
}
|
|
113
|
+
// Insert before the first site block AND before the first project marker: a block placed
|
|
114
|
+
// between `# >>> lt-dev:<slug> >>>` and its site would be removed with that project.
|
|
115
|
+
const block = ['{', `\t${LOOPBACK_BIND}`, '}'];
|
|
116
|
+
const insertAt = lines.findIndex((line) => line.trim() !== '' && (!line.trim().startsWith('#') || line.trim().startsWith('# >>> lt-dev:')));
|
|
117
|
+
if (insertAt === -1) {
|
|
118
|
+
const body = content.replace(/\n+$/, '');
|
|
119
|
+
return body ? `${body}\n\n${block.join('\n')}\n` : `${block.join('\n')}\n`;
|
|
120
|
+
}
|
|
121
|
+
const head = lines.slice(0, insertAt);
|
|
122
|
+
while (head.length > 0 && head[head.length - 1].trim() === '')
|
|
123
|
+
head.pop();
|
|
124
|
+
return [...head, ...(head.length > 0 ? [''] : []), ...block, '', ...lines.slice(insertAt)].join('\n');
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Add the loopback bind to an existing Caddyfile that lacks it. For paths that do not upsert a
|
|
128
|
+
* project block, such as `lt dev up` on a stack that is already running: without this, a stack
|
|
129
|
+
* started before the bind existed stays reachable from the LAN until its next restart.
|
|
130
|
+
* @returns true if the Caddyfile was changed (the caller reloads Caddy)
|
|
131
|
+
*/
|
|
132
|
+
function healLoopbackBind() {
|
|
133
|
+
const current = readCaddyfile();
|
|
134
|
+
if (!current || ensureLoopbackBind(current) === current) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
writeCaddyfile(current);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
73
140
|
/** Read the current Caddyfile (or empty string). */
|
|
74
141
|
function readCaddyfile() {
|
|
75
142
|
if (!(0, fs_1.existsSync)(CADDYFILE_PATH))
|
|
@@ -165,7 +232,10 @@ function upsertProjectBlock(slug, routes) {
|
|
|
165
232
|
else {
|
|
166
233
|
next = current.length > 0 ? `${current.replace(/\n+$/, '')}\n\n${block}` : block;
|
|
167
234
|
}
|
|
168
|
-
|
|
235
|
+
// Also heals a Caddyfile written before the loopback bind existed: the reload that follows
|
|
236
|
+
// every upsert then moves the running Caddy off the LAN interfaces.
|
|
237
|
+
next = ensureLoopbackBind(next);
|
|
238
|
+
if (next.replace(/\s+$/, '') === current.replace(/\s+$/, ''))
|
|
169
239
|
return false;
|
|
170
240
|
writeCaddyfile(next);
|
|
171
241
|
return true;
|
|
@@ -179,7 +249,7 @@ function validateCaddyfile() {
|
|
|
179
249
|
/** Write the Caddyfile, ensuring the parent directory exists. */
|
|
180
250
|
function writeCaddyfile(content) {
|
|
181
251
|
(0, fs_1.mkdirSync)((0, path_1.dirname)(CADDYFILE_PATH), { recursive: true });
|
|
182
|
-
const next = content.startsWith('#') ? content : `${HEADER}\n\n${content}
|
|
252
|
+
const next = ensureLoopbackBind(content.startsWith('#') ? content : `${HEADER}\n\n${content}`);
|
|
183
253
|
(0, fs_1.writeFileSync)(CADDYFILE_PATH, next.endsWith('\n') ? next : `${next}\n`, 'utf8');
|
|
184
254
|
}
|
|
185
255
|
/** Run a caddy subcommand and capture stdout/stderr. */
|
package/build/lib/claude-cli.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.CLAUDE_KNOWN_MARKETPLACES_PATH = exports.CLAUDE_MARKETPLACES_DIR = void
|
|
|
4
4
|
exports.checkCommandExists = checkCommandExists;
|
|
5
5
|
exports.checkMarketplaceExists = checkMarketplaceExists;
|
|
6
6
|
exports.findClaudeCli = findClaudeCli;
|
|
7
|
+
exports.getClaudeConfigDir = getClaudeConfigDir;
|
|
7
8
|
exports.listKnownMarketplaceNames = listKnownMarketplaceNames;
|
|
8
9
|
exports.readKnownMarketplaces = readKnownMarketplaces;
|
|
9
10
|
exports.runClaudeCommand = runClaudeCommand;
|
|
@@ -18,11 +19,11 @@ const platform_1 = require("./platform");
|
|
|
18
19
|
/**
|
|
19
20
|
* Path to Claude plugins marketplaces directory
|
|
20
21
|
*/
|
|
21
|
-
exports.CLAUDE_MARKETPLACES_DIR = (0, path_1.join)((
|
|
22
|
+
exports.CLAUDE_MARKETPLACES_DIR = (0, path_1.join)(getClaudeConfigDir(), 'plugins', 'marketplaces');
|
|
22
23
|
/**
|
|
23
24
|
* Path to the Claude CLI's registry of known marketplaces (name → source).
|
|
24
25
|
*/
|
|
25
|
-
exports.CLAUDE_KNOWN_MARKETPLACES_PATH = (0, path_1.join)((
|
|
26
|
+
exports.CLAUDE_KNOWN_MARKETPLACES_PATH = (0, path_1.join)(getClaudeConfigDir(), 'plugins', 'known_marketplaces.json');
|
|
26
27
|
/**
|
|
27
28
|
* Check if a shell command exists and succeeds
|
|
28
29
|
* @param command - Command to check (e.g., 'which typescript-language-server')
|
|
@@ -62,6 +63,20 @@ function findClaudeCli(options = {}) {
|
|
|
62
63
|
: [path_1.posix.join(home, '.claude', 'local', 'claude'), '/usr/local/bin/claude', '/usr/bin/claude'];
|
|
63
64
|
return (0, platform_1.findExecutable)('claude', Object.assign(Object.assign({}, options), { candidates, env, platform }));
|
|
64
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Claude Code's configuration directory: `CLAUDE_CONFIG_DIR` when set, else `~/.claude`.
|
|
68
|
+
* settings.json, the marketplace clones and the plugin cache all live below it, and the
|
|
69
|
+
* `claude` child processes this CLI starts inherit the variable, so every Claude path the
|
|
70
|
+
* CLI derives has to start here or it reads and writes a different installation.
|
|
71
|
+
* @param env - Environment to read (injectable for tests)
|
|
72
|
+
* @param home - Home directory (injectable for tests)
|
|
73
|
+
* @returns Absolute path of the configuration directory
|
|
74
|
+
*/
|
|
75
|
+
function getClaudeConfigDir(env = process.env, home = (0, os_1.homedir)()) {
|
|
76
|
+
var _a;
|
|
77
|
+
const configured = (_a = env.CLAUDE_CONFIG_DIR) === null || _a === void 0 ? void 0 : _a.trim();
|
|
78
|
+
return configured ? (0, path_1.resolve)(configured) : (0, path_1.join)(home, '.claude');
|
|
79
|
+
}
|
|
65
80
|
/**
|
|
66
81
|
* List the names of all marketplaces known to the Claude CLI.
|
|
67
82
|
* @returns Array of marketplace names
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.execArgs = execArgs;
|
|
4
|
+
exports.hasCmdShimHazard = hasCmdShimHazard;
|
|
5
|
+
exports.isSafeRef = isSafeRef;
|
|
6
|
+
exports.safeRef = safeRef;
|
|
7
|
+
exports.spawnArgs = spawnArgs;
|
|
8
|
+
/**
|
|
9
|
+
* Run a program with an argument list, without a shell.
|
|
10
|
+
*
|
|
11
|
+
* gluegun's `system.run` hands its whole string to `child_process.exec`, i.e. to `/bin/sh` or
|
|
12
|
+
* `cmd.exe`. Every value interpolated into that string is shell syntax there: a branch named
|
|
13
|
+
* `main;curl …|sh`, a remote URL, a commit message, a value from a repository's `lt.config.json`.
|
|
14
|
+
* Quoting does not fix it portably (`cmd.exe` has no single quotes), and validation cannot cover
|
|
15
|
+
* free text. Git itself allows `;`, `|`, `$`, `(` and backticks in ref names, so even a name read
|
|
16
|
+
* back from git output is not safe to interpolate. With an argument list nothing is parsed.
|
|
17
|
+
*/
|
|
18
|
+
const child_process_1 = require("child_process");
|
|
19
|
+
const platform_1 = require("./platform");
|
|
20
|
+
/**
|
|
21
|
+
* Run `command` with `args` and resolve with its stdout. Rejects like gluegun's `system.run`:
|
|
22
|
+
* with the child_process error (message `Command failed: …` plus stderr) carrying `stdout`
|
|
23
|
+
* and `stderr`, so existing error handling keeps working unchanged.
|
|
24
|
+
* @param command - Program to run, looked up on PATH (no shell, so no `.cmd` shims on Windows)
|
|
25
|
+
* @param args - Arguments, passed through verbatim
|
|
26
|
+
* @param options - Working directory, environment, trimming
|
|
27
|
+
* @returns stdout of the program
|
|
28
|
+
*/
|
|
29
|
+
function execArgs(command, args, options = {}) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
var _a;
|
|
32
|
+
(0, child_process_1.execFile)(command, [...args], { cwd: options.cwd, env: (_a = options.env) !== null && _a !== void 0 ? _a : process.env, maxBuffer: 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => {
|
|
33
|
+
if (error) {
|
|
34
|
+
Object.assign(error, { stderr, stdout });
|
|
35
|
+
reject(error);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const out = String(stdout !== null && stdout !== void 0 ? stdout : '');
|
|
39
|
+
resolve(options.trim ? out.trim() : out);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Whether a value is unsafe as an argument to {@link spawnArgs} on Windows. cross-spawn runs a
|
|
45
|
+
* `.cmd` shim through `cmd.exe` with escaped arguments, but the shim re-parses its `%*`: a `"`
|
|
46
|
+
* ends the quoting, `%` expands variables and a line break ends the command. On macOS and
|
|
47
|
+
* Linux there is no shell and every value is safe.
|
|
48
|
+
* @param value - Argument from a flag or a config file
|
|
49
|
+
* @param platform - `process.platform`
|
|
50
|
+
* @returns true if the value must be refused before it reaches a Windows shim
|
|
51
|
+
*/
|
|
52
|
+
function hasCmdShimHazard(value, platform = process.platform) {
|
|
53
|
+
return platform === 'win32' && /["%\r\n]/.test(value);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Whether a value can be passed to git as a ref argument (see {@link safeRef}). Surrounding
|
|
57
|
+
* whitespace is ignored: values read back from git output end in a newline.
|
|
58
|
+
* @param ref - Ref name from a flag, a config file or git output
|
|
59
|
+
* @returns false if the ref is empty, starts with `-`, or contains whitespace or control characters
|
|
60
|
+
*/
|
|
61
|
+
function isSafeRef(ref) {
|
|
62
|
+
// String(): gluegun hands a digits-only value over as a number (`lt git create 2314 main`).
|
|
63
|
+
const value = String(ref !== null && ref !== void 0 ? ref : '').trim();
|
|
64
|
+
const hasControlChar = [...value].some((char) => char.charCodeAt(0) < 0x20 || char.charCodeAt(0) === 0x7f);
|
|
65
|
+
return Boolean(value) && !value.startsWith('-') && !/\s/.test(value) && !hasControlChar;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Guard a git ref (branch, tag, base) that is passed as a positional argument. Without a shell
|
|
69
|
+
* the value cannot run a command, but git would still read a leading `-` as an option
|
|
70
|
+
* (`git checkout --orphan=x`). Git refuses such branch names itself, as it refuses whitespace
|
|
71
|
+
* and control characters, so no legitimate ref is rejected.
|
|
72
|
+
* @param ref - Ref name from a flag, a config file or git output
|
|
73
|
+
* @returns The ref without surrounding whitespace (git output ends in a newline, which the
|
|
74
|
+
* former shell strings silently treated as the end of the command)
|
|
75
|
+
* @throws If the ref is empty, starts with `-`, or contains whitespace or control characters
|
|
76
|
+
*/
|
|
77
|
+
function safeRef(ref) {
|
|
78
|
+
if (!isSafeRef(ref)) {
|
|
79
|
+
throw new Error(`Refusing "${ref}" as a git ref: it is empty, starts with "-", or contains whitespace`);
|
|
80
|
+
}
|
|
81
|
+
return String(ref).trim();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* {@link execArgs} for programs that are npm `.cmd` shims on Windows (npm, pnpm, npx, a `bun`
|
|
85
|
+
* or any CLI installed with `npm i -g`): `execFile` cannot start a `.cmd`. Delegates to
|
|
86
|
+
* cross-spawn, which uses no shell on macOS/Linux and escaped `cmd.exe` arguments on Windows,
|
|
87
|
+
* where callers must refuse values that {@link hasCmdShimHazard} reports. Asynchronous on
|
|
88
|
+
* purpose: a synchronous spawn under a running spinner blocks Ctrl+C until the child exits.
|
|
89
|
+
* @param command - Program to run
|
|
90
|
+
* @param args - Arguments, passed through verbatim
|
|
91
|
+
* @param options - Working directory and environment
|
|
92
|
+
* @returns stdout of the program
|
|
93
|
+
*/
|
|
94
|
+
function spawnArgs(command, args, options = {}) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
var _a, _b, _c;
|
|
97
|
+
const child = (0, platform_1.spawnCmd)(command, args, {
|
|
98
|
+
cwd: options.cwd,
|
|
99
|
+
env: (_a = options.env) !== null && _a !== void 0 ? _a : process.env,
|
|
100
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
101
|
+
windowsHide: true,
|
|
102
|
+
});
|
|
103
|
+
let stdout = '';
|
|
104
|
+
let stderr = '';
|
|
105
|
+
(_b = child.stdout) === null || _b === void 0 ? void 0 : _b.on('data', (chunk) => (stdout += String(chunk)));
|
|
106
|
+
(_c = child.stderr) === null || _c === void 0 ? void 0 : _c.on('data', (chunk) => (stderr += String(chunk)));
|
|
107
|
+
child.on('error', reject);
|
|
108
|
+
child.on('close', (code) => {
|
|
109
|
+
if (code === 0) {
|
|
110
|
+
resolve(stdout);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const error = new Error(`Command failed: ${command} ${args.join(' ')}\n${stderr}`);
|
|
114
|
+
reject(Object.assign(error, { code, stderr, stdout }));
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getPermissionLedgerPath = getPermissionLedgerPath;
|
|
4
|
+
exports.hasPluginRootVariable = hasPluginRootVariable;
|
|
5
|
+
exports.planPermissionSync = planPermissionSync;
|
|
6
|
+
exports.pluginCacheRootPattern = pluginCacheRootPattern;
|
|
7
|
+
exports.pluginPermissionKey = pluginPermissionKey;
|
|
8
|
+
exports.readLedgerEntries = readLedgerEntries;
|
|
9
|
+
exports.recordLedgerEntry = recordLedgerEntry;
|
|
10
|
+
exports.resolvePluginRootVariable = resolvePluginRootVariable;
|
|
11
|
+
/**
|
|
12
|
+
* Permission rules that `lt claude plugins` writes into Claude Code's user settings.json.
|
|
13
|
+
*
|
|
14
|
+
* Those rules apply in every project, and in auto mode an allow rule takes effect before
|
|
15
|
+
* the classifier sees the call. Appending the rules a plugin ships is therefore not enough:
|
|
16
|
+
*
|
|
17
|
+
* - A rule a plugin stops shipping has to leave settings.json again. The ledger
|
|
18
|
+
* (`~/.lenneTech/claude-plugin-permissions.json`) records, per settings file and plugin,
|
|
19
|
+
* which rules lt wrote. Each run removes the recorded rules the plugin no longer lists.
|
|
20
|
+
* A rule the user wrote is never recorded, so it stays.
|
|
21
|
+
* - Rules lt wrote before the ledger existed are not recorded anywhere. Each plugin names
|
|
22
|
+
* them in `formerPatterns` of its permissions.json, so this public CLI carries no list of
|
|
23
|
+
* them (an internal plugin's former rules name internal hosts). Each one is applied once.
|
|
24
|
+
* - A rule with `${CLAUDE_PLUGIN_ROOT}` is never written. Claude Code does not expand the
|
|
25
|
+
* variable in permission rules, so the rule as shipped never matches. Resolving it to the
|
|
26
|
+
* cache directory needs a wildcard for the version folder, and Claude Code neither
|
|
27
|
+
* normalises paths nor stops a wildcard at `/`. A rule allowing `bash <cache>/<plugin>/`
|
|
28
|
+
* followed by any version and `/scripts/` and any name also allowed
|
|
29
|
+
* `bash <cache>/<plugin>/1.0.0/scripts/../../../../../../dev/stdin`, which ran piped code
|
|
30
|
+
* without a prompt (measured 2026-10-18, also for node). Such rules are removed in both
|
|
31
|
+
* spellings.
|
|
32
|
+
*/
|
|
33
|
+
const fs_1 = require("fs");
|
|
34
|
+
const path_1 = require("path");
|
|
35
|
+
const json_utils_1 = require("./json-utils");
|
|
36
|
+
const marketplace_config_1 = require("./marketplace-config");
|
|
37
|
+
const LEDGER_VERSION = 1;
|
|
38
|
+
/**
|
|
39
|
+
* `${CLAUDE_PLUGIN_ROOT}` and the bare `$CLAUDE_PLUGIN_ROOT`
|
|
40
|
+
*/
|
|
41
|
+
const PLUGIN_ROOT_VARIABLE = /\$\{CLAUDE_PLUGIN_ROOT\}|\$CLAUDE_PLUGIN_ROOT(?!\w)/g;
|
|
42
|
+
/**
|
|
43
|
+
* Path of the ledger. Resolved at call time so tests can redirect it.
|
|
44
|
+
* @returns Absolute path to claude-plugin-permissions.json
|
|
45
|
+
*/
|
|
46
|
+
function getPermissionLedgerPath() {
|
|
47
|
+
return process.env.LT_CLAUDE_PERMISSION_LEDGER || (0, path_1.join)((0, marketplace_config_1.getLtConfigDir)(), 'claude-plugin-permissions.json');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Whether a rule contains `${CLAUDE_PLUGIN_ROOT}`
|
|
51
|
+
* @param rule - Permission rule as the plugin ships it
|
|
52
|
+
* @returns true if the rule still needs resolving
|
|
53
|
+
*/
|
|
54
|
+
function hasPluginRootVariable(rule) {
|
|
55
|
+
// `search` ignores the g flag and lastIndex, so the shared regex stays stateless here.
|
|
56
|
+
return rule.search(PLUGIN_ROOT_VARIABLE) !== -1;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Decide what a run changes in `permissions.allow` for one plugin. Pure.
|
|
60
|
+
*
|
|
61
|
+
* Adds every listed rule that is missing. Removes a rule only when lt wrote it for this
|
|
62
|
+
* plugin, the plugin no longer lists it and the ledger does not record it for another
|
|
63
|
+
* plugin. "lt wrote it" means: the ledger records it, or the plugin names it in
|
|
64
|
+
* `formerPatterns` and it has not been applied before, or it is a withdrawn rule. Rules
|
|
65
|
+
* nobody recorded are the user's.
|
|
66
|
+
*
|
|
67
|
+
* Each former rule is applied once, whenever it first shows up, not only on the first run:
|
|
68
|
+
* a CLI that meets a plugin before the marketplace ships `formerPatterns` must not close
|
|
69
|
+
* the window for good. Once applied, a rule the user adds back by hand stays.
|
|
70
|
+
*
|
|
71
|
+
* Without a ledger entry the listed rules already present are adopted as lt's: lt has been
|
|
72
|
+
* their installer. A user who wrote an identical rule by hand may then see it removed once
|
|
73
|
+
* the plugin drops it, which costs a prompt. Leaving a broad rule lt wrote in place would
|
|
74
|
+
* cost more.
|
|
75
|
+
* @param input - Current settings, ledger state and the plugin's rules
|
|
76
|
+
* @returns Rules to add and remove, the next allow list and the next ledger entry
|
|
77
|
+
*/
|
|
78
|
+
function planPermissionSync(input) {
|
|
79
|
+
var _a, _b;
|
|
80
|
+
const listed = [...new Set(input.listed)];
|
|
81
|
+
const present = new Set(input.allow);
|
|
82
|
+
const added = listed.filter((rule) => !present.has(rule));
|
|
83
|
+
const existing = listed.filter((rule) => present.has(rule));
|
|
84
|
+
const applied = new Set(input.formerApplied);
|
|
85
|
+
const formerDue = input.formerRules.filter((rule) => !applied.has(rule));
|
|
86
|
+
const writtenByLt = new Set([...((_a = input.owned) !== null && _a !== void 0 ? _a : []), ...formerDue, ...input.withdrawnRules]);
|
|
87
|
+
const keep = new Set([...listed, ...input.ownedByOthers]);
|
|
88
|
+
const removed = [...writtenByLt].filter((rule) => present.has(rule) && !keep.has(rule));
|
|
89
|
+
const removedSet = new Set(removed);
|
|
90
|
+
const ownedBefore = new Set([...((_b = input.owned) !== null && _b !== void 0 ? _b : existing), ...added]);
|
|
91
|
+
return {
|
|
92
|
+
added,
|
|
93
|
+
allow: [...input.allow.filter((rule) => !removedSet.has(rule)), ...added],
|
|
94
|
+
existing,
|
|
95
|
+
ledger: {
|
|
96
|
+
formerApplied: [...new Set([...input.formerApplied, ...input.formerRules])],
|
|
97
|
+
owned: listed.filter((rule) => ownedBefore.has(rule)),
|
|
98
|
+
},
|
|
99
|
+
removed,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The plugin's cache directory (`<config dir>/plugins/cache/<marketplace>/<plugin>/<version>/`)
|
|
104
|
+
* with a wildcard for the version folder: the form a pre-release build put in place of
|
|
105
|
+
* `${CLAUDE_PLUGIN_ROOT}`. Used only to recognise and remove such rules. NEVER write a rule
|
|
106
|
+
* built from it: the `*` also matches `../`, so the rule allows any file (see the file header).
|
|
107
|
+
* @param claudeConfigDir - Claude Code's configuration directory
|
|
108
|
+
* @param marketplaceName - Name of the marketplace
|
|
109
|
+
* @param pluginName - Name of the plugin
|
|
110
|
+
* @returns Absolute path pattern ending in the version wildcard
|
|
111
|
+
*/
|
|
112
|
+
function pluginCacheRootPattern(claudeConfigDir, marketplaceName, pluginName) {
|
|
113
|
+
return (0, path_1.join)(claudeConfigDir, 'plugins', 'cache', marketplaceName, pluginName, '*');
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Key of a plugin in the ledger
|
|
117
|
+
* @param marketplaceName - Name of the marketplace
|
|
118
|
+
* @param pluginName - Name of the plugin
|
|
119
|
+
* @returns `<marketplace>/<plugin>`
|
|
120
|
+
*/
|
|
121
|
+
function pluginPermissionKey(marketplaceName, pluginName) {
|
|
122
|
+
return `${marketplaceName}/${pluginName}`;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Ledger entries for one settings file. Never throws: a missing or unreadable ledger reads
|
|
126
|
+
* as empty, which only means every plugin is treated as seen for the first time.
|
|
127
|
+
* @param ledgerPath - Path of the ledger
|
|
128
|
+
* @param settingsPath - settings.json the rules were written to
|
|
129
|
+
* @returns `<marketplace>/<plugin>` → entry
|
|
130
|
+
*/
|
|
131
|
+
function readLedgerEntries(ledgerPath, settingsPath) {
|
|
132
|
+
var _a;
|
|
133
|
+
return (_a = readLedger(ledgerPath).settings[settingsPath]) !== null && _a !== void 0 ? _a : {};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Record a plugin's ledger entry. Written via temp file + rename, so an interrupted run
|
|
137
|
+
* cannot leave a truncated ledger.
|
|
138
|
+
* @param ledgerPath - Path of the ledger
|
|
139
|
+
* @param settingsPath - settings.json the rules were written to
|
|
140
|
+
* @param pluginKey - `<marketplace>/<plugin>`
|
|
141
|
+
* @param entry - The plugin's entry
|
|
142
|
+
*/
|
|
143
|
+
function recordLedgerEntry(ledgerPath, settingsPath, pluginKey, entry) {
|
|
144
|
+
const ledger = readLedger(ledgerPath);
|
|
145
|
+
ledger.settings[settingsPath] = Object.assign(Object.assign({}, ledger.settings[settingsPath]), { [pluginKey]: entry });
|
|
146
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(ledgerPath), { recursive: true });
|
|
147
|
+
const tmpPath = `${ledgerPath}.${process.pid}.tmp`;
|
|
148
|
+
(0, fs_1.writeFileSync)(tmpPath, `${JSON.stringify(ledger, null, 2)}\n`);
|
|
149
|
+
(0, fs_1.renameSync)(tmpPath, ledgerPath);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Replace `${CLAUDE_PLUGIN_ROOT}` in a rule. A rule without the variable comes back unchanged.
|
|
153
|
+
* @param rule - Permission rule as the plugin ships it
|
|
154
|
+
* @param pluginRoot - Replacement, usually {@link pluginCacheRootPattern}
|
|
155
|
+
* @returns The rule as it has to be written to settings.json
|
|
156
|
+
*/
|
|
157
|
+
function resolvePluginRootVariable(rule, pluginRoot) {
|
|
158
|
+
// A replacer function, so a `$` in the path is not read as a replacement pattern.
|
|
159
|
+
return rule.replace(PLUGIN_ROOT_VARIABLE, () => pluginRoot);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Read and validate the ledger. Entries of the wrong shape are dropped.
|
|
163
|
+
* @param ledgerPath - Path of the ledger
|
|
164
|
+
* @returns The ledger, empty when missing or unreadable
|
|
165
|
+
*/
|
|
166
|
+
function readLedger(ledgerPath) {
|
|
167
|
+
const ledger = { settings: {}, version: LEDGER_VERSION };
|
|
168
|
+
let parsed = null;
|
|
169
|
+
try {
|
|
170
|
+
parsed = (0, fs_1.existsSync)(ledgerPath)
|
|
171
|
+
? (0, json_utils_1.safeJsonParse)((0, fs_1.readFileSync)(ledgerPath, 'utf-8'))
|
|
172
|
+
: null;
|
|
173
|
+
}
|
|
174
|
+
catch (_a) {
|
|
175
|
+
return ledger;
|
|
176
|
+
}
|
|
177
|
+
if (!parsed || typeof parsed.settings !== 'object' || parsed.settings === null) {
|
|
178
|
+
return ledger;
|
|
179
|
+
}
|
|
180
|
+
for (const [settingsPath, plugins] of Object.entries(parsed.settings)) {
|
|
181
|
+
if (typeof plugins !== 'object' || plugins === null) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const entries = {};
|
|
185
|
+
for (const [pluginKey, entry] of Object.entries(plugins)) {
|
|
186
|
+
if (typeof entry === 'object' && entry !== null && Array.isArray(entry.owned)) {
|
|
187
|
+
entries[pluginKey] = { formerApplied: stringsOf(entry.formerApplied), owned: stringsOf(entry.owned) };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
ledger.settings[settingsPath] = entries;
|
|
191
|
+
}
|
|
192
|
+
return ledger;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The string elements of a value that should be a string list
|
|
196
|
+
* @param value - Parsed JSON value
|
|
197
|
+
* @returns Its strings, or an empty list
|
|
198
|
+
*/
|
|
199
|
+
function stringsOf(value) {
|
|
200
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
201
|
+
}
|