@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.
Files changed (44) hide show
  1. package/build/commands/claude/plugins.js +29 -16
  2. package/build/commands/cli/create.js +11 -3
  3. package/build/commands/cli/rename.js +10 -2
  4. package/build/commands/dev/up.js +9 -0
  5. package/build/commands/directus/docker-setup.js +2 -2
  6. package/build/commands/directus/remove.js +1 -1
  7. package/build/commands/directus/typegen.js +31 -4
  8. package/build/commands/frontend/convert-mode.js +16 -0
  9. package/build/commands/frontend/nuxt.js +1 -1
  10. package/build/commands/fullstack/add-api.js +1 -2
  11. package/build/commands/fullstack/add-app.js +1 -1
  12. package/build/commands/fullstack/convert-mode.js +20 -0
  13. package/build/commands/fullstack/init.js +9 -6
  14. package/build/commands/git/clean.js +3 -2
  15. package/build/commands/git/create.js +7 -2
  16. package/build/commands/git/force-pull.js +4 -2
  17. package/build/commands/git/get.js +19 -6
  18. package/build/commands/git/rebase.js +13 -3
  19. package/build/commands/git/rename.js +5 -4
  20. package/build/commands/git/reset.js +18 -10
  21. package/build/commands/git/squash.js +4 -2
  22. package/build/commands/git/update.js +2 -1
  23. package/build/commands/npm/reinit.js +3 -3
  24. package/build/commands/server/add-property.js +10 -0
  25. package/build/commands/server/convert-mode.js +16 -0
  26. package/build/commands/server/permissions.js +10 -10
  27. package/build/commands/starter/chrome-extension.js +8 -2
  28. package/build/commands/typescript/create.js +3 -2
  29. package/build/extensions/api-mode.js +1 -1
  30. package/build/extensions/frontend-helper.js +25 -13
  31. package/build/extensions/git.js +25 -14
  32. package/build/extensions/server.js +14 -5
  33. package/build/extensions/template.js +8 -5
  34. package/build/extensions/typescript.js +4 -3
  35. package/build/lib/caddy.js +72 -2
  36. package/build/lib/claude-cli.js +17 -2
  37. package/build/lib/exec-args.js +117 -0
  38. package/build/lib/plugin-permissions.js +201 -0
  39. package/build/lib/plugin-utils.js +96 -36
  40. package/build/lib/safe-paths.js +57 -0
  41. package/build/lib/unknown-options.js +53 -0
  42. package/build/lib/workspace-integration.js +4 -2
  43. package/docs/commands.md +12 -0
  44. 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 }, system, } = this.toolbox;
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
- yield system.run(`ln -s "${templatePath}" "${dest}"`);
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 }, system, } = this.toolbox;
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
- yield system.run(`git clone${branchArg} ${repoUrl} "${dest}"`);
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(`cd ${dir} && npm init -y && npx gts init && npm install -D ts-node`);
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 run(`git clone ${repository} ${dir}`);
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(`cd ${dir} && npm start`);
156
+ yield run('npm start', { cwd: dir });
156
157
  });
157
158
  }
158
159
  }
@@ -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
- if (next === current.replace(/\s+$/, ''))
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. */
@@ -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)((0, os_1.homedir)(), '.claude', 'plugins', 'marketplaces');
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)((0, os_1.homedir)(), '.claude', 'plugins', 'known_marketplaces.json');
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
+ }