@lpm-registry/cli 0.2.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 (54) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +15 -0
  3. package/README.md +406 -0
  4. package/bin/lpm.js +334 -0
  5. package/index.d.ts +131 -0
  6. package/index.js +31 -0
  7. package/lib/api.js +324 -0
  8. package/lib/commands/add.js +1217 -0
  9. package/lib/commands/audit.js +283 -0
  10. package/lib/commands/cache.js +209 -0
  11. package/lib/commands/check-name.js +112 -0
  12. package/lib/commands/config.js +174 -0
  13. package/lib/commands/doctor.js +142 -0
  14. package/lib/commands/info.js +215 -0
  15. package/lib/commands/init.js +146 -0
  16. package/lib/commands/install.js +217 -0
  17. package/lib/commands/login.js +547 -0
  18. package/lib/commands/logout.js +94 -0
  19. package/lib/commands/marketplace-compare.js +164 -0
  20. package/lib/commands/marketplace-earnings.js +89 -0
  21. package/lib/commands/mcp-setup.js +363 -0
  22. package/lib/commands/open.js +82 -0
  23. package/lib/commands/outdated.js +291 -0
  24. package/lib/commands/pool-stats.js +100 -0
  25. package/lib/commands/publish.js +707 -0
  26. package/lib/commands/quality.js +211 -0
  27. package/lib/commands/remove.js +82 -0
  28. package/lib/commands/run.js +14 -0
  29. package/lib/commands/search.js +143 -0
  30. package/lib/commands/setup.js +92 -0
  31. package/lib/commands/skills.js +863 -0
  32. package/lib/commands/token-rotate.js +25 -0
  33. package/lib/commands/whoami.js +129 -0
  34. package/lib/config.js +240 -0
  35. package/lib/constants.js +190 -0
  36. package/lib/ecosystem.js +501 -0
  37. package/lib/editors.js +215 -0
  38. package/lib/import-rewriter.js +364 -0
  39. package/lib/install-targets/mcp-server.js +245 -0
  40. package/lib/install-targets/vscode-extension.js +178 -0
  41. package/lib/install-targets.js +82 -0
  42. package/lib/integrity.js +179 -0
  43. package/lib/lpm-config-prompts.js +102 -0
  44. package/lib/lpm-config.js +408 -0
  45. package/lib/project-utils.js +152 -0
  46. package/lib/quality/checks.js +654 -0
  47. package/lib/quality/display.js +139 -0
  48. package/lib/quality/score.js +115 -0
  49. package/lib/quality/swift-checks.js +447 -0
  50. package/lib/safe-path.js +180 -0
  51. package/lib/secure-store.js +288 -0
  52. package/lib/swift-project.js +637 -0
  53. package/lib/ui.js +40 -0
  54. package/package.json +74 -0
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Smart Import Rewriter for LPM Source Packages
3
+ *
4
+ * Resolves import specifiers against the installed file set and rewrites
5
+ * internal imports to match the buyer's project alias setup.
6
+ *
7
+ * Pure-function module — no I/O, no fs, no side effects.
8
+ * All path logic uses forward-slash (POSIX) conventions.
9
+ *
10
+ * @module cli/lib/import-rewriter
11
+ */
12
+
13
+ /**
14
+ * @typedef {Object} RewriteOptions
15
+ * @property {string} fileDestPath - Dest-relative path of the file being processed
16
+ * @property {Set<string>} destFileSet - All dest-relative paths being installed
17
+ * @property {string} [fileSrcPath] - Src-relative path (for relative import resolution)
18
+ * @property {Map<string,string>} [srcToDestMap] - Maps src paths → dest paths
19
+ * @property {string} [authorAlias] - Author's import alias prefix (e.g., "@/")
20
+ * @property {string} [buyerAlias] - Buyer's alias for the dest directory (e.g., "@/components/design-system")
21
+ */
22
+
23
+ /** File extensions to try when resolving extensionless imports */
24
+ const EXTENSIONS = [".js", ".jsx", ".ts", ".tsx"]
25
+
26
+ /**
27
+ * Matches import/export specifiers on a single line.
28
+ *
29
+ * Captures:
30
+ * - from 'specifier' / from "specifier"
31
+ * - import 'specifier' / import "specifier" (side-effect)
32
+ * - import('specifier') / import("specifier") (dynamic)
33
+ * - export { x } from 'specifier'
34
+ *
35
+ * Groups: $1 = keyword prefix, $2 = quote char, $3 = specifier
36
+ */
37
+ const SPECIFIER_RE = /(from\s+|import\s*\(\s*|import\s+)(['"])(.*?)\2/g
38
+
39
+ /**
40
+ * Rewrite internal imports in file content to use the buyer's alias.
41
+ *
42
+ * @param {string} content - File source content
43
+ * @param {RewriteOptions} options
44
+ * @returns {string} Content with internal imports rewritten
45
+ */
46
+ export function rewriteImports(content, options) {
47
+ const {
48
+ fileDestPath,
49
+ destFileSet,
50
+ fileSrcPath,
51
+ srcToDestMap,
52
+ authorAlias,
53
+ buyerAlias,
54
+ } = options
55
+
56
+ // Nothing to do if no buyer alias (relative imports already work)
57
+ // and no author alias to resolve
58
+ if (!buyerAlias && !authorAlias) return content
59
+
60
+ const fileDestDir = dirname(fileDestPath)
61
+ const fileSrcDir = fileSrcPath ? dirname(fileSrcPath) : fileDestDir
62
+
63
+ // Build src file set from srcToDestMap keys for author alias lookups
64
+ const srcFileSet = srcToDestMap ? new Set(srcToDestMap.keys()) : null
65
+
66
+ const lines = content.split("\n")
67
+ const result = []
68
+ let inBlockComment = false
69
+
70
+ for (const line of lines) {
71
+ // Track block comments (best-effort)
72
+ if (inBlockComment) {
73
+ if (line.includes("*/")) inBlockComment = false
74
+ result.push(line)
75
+ continue
76
+ }
77
+ if (line.trimStart().startsWith("/*")) {
78
+ if (!line.includes("*/")) inBlockComment = true
79
+ result.push(line)
80
+ continue
81
+ }
82
+ // Skip single-line comments
83
+ if (line.trimStart().startsWith("//")) {
84
+ result.push(line)
85
+ continue
86
+ }
87
+
88
+ result.push(
89
+ rewriteLineImports(
90
+ line,
91
+ fileDestDir,
92
+ fileSrcDir,
93
+ destFileSet,
94
+ srcFileSet,
95
+ srcToDestMap,
96
+ authorAlias,
97
+ buyerAlias,
98
+ ),
99
+ )
100
+ }
101
+
102
+ return result.join("\n")
103
+ }
104
+
105
+ /**
106
+ * Rewrite import specifiers on a single line.
107
+ *
108
+ * @param {string} line
109
+ * @param {string} fileDestDir
110
+ * @param {string} fileSrcDir
111
+ * @param {Set<string>} destFileSet
112
+ * @param {Set<string>|null} srcFileSet
113
+ * @param {Map<string,string>|null} srcToDestMap
114
+ * @param {string} [authorAlias]
115
+ * @param {string} [buyerAlias]
116
+ * @returns {string}
117
+ */
118
+ function rewriteLineImports(
119
+ line,
120
+ fileDestDir,
121
+ fileSrcDir,
122
+ destFileSet,
123
+ srcFileSet,
124
+ srcToDestMap,
125
+ authorAlias,
126
+ buyerAlias,
127
+ ) {
128
+ return line.replace(SPECIFIER_RE, (match, prefix, quote, specifier) => {
129
+ const resolvedDestPath = resolveSpecifier(
130
+ specifier,
131
+ fileDestDir,
132
+ fileSrcDir,
133
+ destFileSet,
134
+ srcFileSet,
135
+ srcToDestMap,
136
+ authorAlias,
137
+ )
138
+
139
+ if (!resolvedDestPath) {
140
+ // External import or unresolvable — leave unchanged
141
+ return match
142
+ }
143
+
144
+ // Internal import — compute new specifier
145
+ const newSpecifier = computeNewSpecifier(
146
+ resolvedDestPath,
147
+ fileDestDir,
148
+ buyerAlias,
149
+ )
150
+
151
+ if (!newSpecifier) {
152
+ // No buyer alias and cannot compute — leave unchanged
153
+ return match
154
+ }
155
+
156
+ return `${prefix}${quote}${newSpecifier}${quote}`
157
+ })
158
+ }
159
+
160
+ /**
161
+ * Resolve an import specifier to a dest file path if it's internal.
162
+ *
163
+ * @param {string} specifier - The import specifier
164
+ * @param {string} fileDestDir - Dest directory of the importing file
165
+ * @param {string} fileSrcDir - Src directory of the importing file
166
+ * @param {Set<string>} destFileSet - All dest paths
167
+ * @param {Set<string>|null} srcFileSet - All src paths (for author alias)
168
+ * @param {Map<string,string>|null} srcToDestMap - src → dest mapping
169
+ * @param {string} [authorAlias] - Author's import alias prefix
170
+ * @returns {string|null} Resolved dest path, or null if external
171
+ */
172
+ function resolveSpecifier(
173
+ specifier,
174
+ fileDestDir,
175
+ fileSrcDir,
176
+ destFileSet,
177
+ srcFileSet,
178
+ srcToDestMap,
179
+ authorAlias,
180
+ ) {
181
+ // 1. Relative import: ./foo or ../bar
182
+ if (specifier.startsWith("./") || specifier.startsWith("../")) {
183
+ // Resolve against src directory, then map to dest
184
+ if (srcToDestMap && srcToDestMap.size > 0) {
185
+ const resolvedSrc = normalizePath(joinPath(fileSrcDir, specifier))
186
+ const srcMatch = tryResolveFile(resolvedSrc, new Set(srcToDestMap.keys()))
187
+ if (srcMatch) {
188
+ return srcToDestMap.get(srcMatch) || null
189
+ }
190
+ }
191
+
192
+ // Fallback: resolve against dest directory directly
193
+ const resolvedDest = normalizePath(joinPath(fileDestDir, specifier))
194
+ return tryResolveFile(resolvedDest, destFileSet)
195
+ }
196
+
197
+ // 2. Author alias import: starts with the declared importAlias
198
+ if (authorAlias && specifier.startsWith(authorAlias)) {
199
+ const aliasRelative = specifier.slice(authorAlias.length)
200
+
201
+ // Look up in src file set, then map to dest
202
+ if (srcFileSet && srcToDestMap) {
203
+ const srcMatch = tryResolveFile(aliasRelative, srcFileSet)
204
+ if (srcMatch) {
205
+ return srcToDestMap.get(srcMatch) || null
206
+ }
207
+ }
208
+
209
+ // Fallback: try direct match in dest file set
210
+ return tryResolveFile(aliasRelative, destFileSet)
211
+ }
212
+
213
+ // 3. Bare specifier (react, next/link, @scope/pkg) — external
214
+ return null
215
+ }
216
+
217
+ /**
218
+ * Resolve an import specifier to a dest file path.
219
+ *
220
+ * Exported for testing.
221
+ *
222
+ * @param {string} specifier - Import specifier
223
+ * @param {string} fileDir - Directory of the importing file
224
+ * @param {Set<string>} fileSet - File paths to match against
225
+ * @param {string} [authorAlias] - Author's import alias prefix
226
+ * @returns {string|null} Matching file path, or null if external
227
+ */
228
+ export function resolveImportToFilePath(
229
+ specifier,
230
+ fileDir,
231
+ fileSet,
232
+ authorAlias,
233
+ ) {
234
+ if (specifier.startsWith("./") || specifier.startsWith("../")) {
235
+ const resolved = normalizePath(joinPath(fileDir, specifier))
236
+ return tryResolveFile(resolved, fileSet)
237
+ }
238
+
239
+ if (authorAlias && specifier.startsWith(authorAlias)) {
240
+ const aliasRelative = specifier.slice(authorAlias.length)
241
+ return tryResolveFile(aliasRelative, fileSet)
242
+ }
243
+
244
+ return null
245
+ }
246
+
247
+ /**
248
+ * Try to resolve a path against a file set, trying extensions and index files.
249
+ *
250
+ * @param {string} candidatePath - Path to resolve (no leading ./)
251
+ * @param {Set<string>} fileSet - Set of file paths
252
+ * @returns {string|null} Matching path or null
253
+ */
254
+ function tryResolveFile(candidatePath, fileSet) {
255
+ // Normalize: remove leading ./
256
+ candidatePath = candidatePath.replace(/^\.\//, "")
257
+
258
+ // 1. Exact match
259
+ if (fileSet.has(candidatePath)) return candidatePath
260
+
261
+ // 2. Try appending extensions
262
+ for (const ext of EXTENSIONS) {
263
+ const withExt = candidatePath + ext
264
+ if (fileSet.has(withExt)) return withExt
265
+ }
266
+
267
+ // 3. Try as directory: append /index.ext
268
+ for (const ext of EXTENSIONS) {
269
+ const indexPath = `${candidatePath}/index${ext}`
270
+ if (fileSet.has(indexPath)) return indexPath
271
+ }
272
+
273
+ return null
274
+ }
275
+
276
+ /**
277
+ * Compute the new import specifier for an internal file.
278
+ *
279
+ * @param {string} resolvedDestPath - Dest path of the imported file
280
+ * @param {string} fileDestDir - Dest directory of the importing file
281
+ * @param {string} [buyerAlias] - Buyer's alias (e.g., "@/components/design-system")
282
+ * @returns {string|null} New specifier, or null if no rewrite needed
283
+ */
284
+ function computeNewSpecifier(resolvedDestPath, _fileDestDir, buyerAlias) {
285
+ const cleanPath = stripImportExtension(resolvedDestPath)
286
+
287
+ if (buyerAlias) {
288
+ // Buyer wants alias-based imports
289
+ const alias = buyerAlias.endsWith("/") ? buyerAlias : `${buyerAlias}/`
290
+ return alias + cleanPath
291
+ }
292
+
293
+ // No buyer alias — no rewrite needed
294
+ return null
295
+ }
296
+
297
+ /**
298
+ * Strip file extension and /index suffix for clean import paths.
299
+ *
300
+ * "components/dialog/Dialog.jsx" → "components/dialog/Dialog"
301
+ * "components/dialog/index.js" → "components/dialog"
302
+ *
303
+ * @param {string} filePath
304
+ * @returns {string}
305
+ */
306
+ function stripImportExtension(filePath) {
307
+ // Strip /index.ext → parent dir
308
+ const indexMatch = filePath.match(/\/index\.(js|jsx|ts|tsx)$/)
309
+ if (indexMatch) {
310
+ return filePath.slice(0, -`/index.${indexMatch[1]}`.length)
311
+ }
312
+
313
+ // Strip .ext
314
+ const extMatch = filePath.match(/\.(js|jsx|ts|tsx)$/)
315
+ if (extMatch) {
316
+ return filePath.slice(0, -`.${extMatch[1]}`.length)
317
+ }
318
+
319
+ return filePath
320
+ }
321
+
322
+ /**
323
+ * Normalize a path by resolving . and .. segments.
324
+ * Pure string operation — no filesystem access.
325
+ *
326
+ * @param {string} p - Path with forward slashes
327
+ * @returns {string} Normalized path
328
+ */
329
+ function normalizePath(p) {
330
+ const segments = p.split("/")
331
+ const resolved = []
332
+ for (const seg of segments) {
333
+ if (seg === "..") {
334
+ resolved.pop()
335
+ } else if (seg !== "." && seg !== "") {
336
+ resolved.push(seg)
337
+ }
338
+ }
339
+ return resolved.join("/")
340
+ }
341
+
342
+ /**
343
+ * Join two path segments with forward slashes.
344
+ *
345
+ * @param {string} base
346
+ * @param {string} relative
347
+ * @returns {string}
348
+ */
349
+ function joinPath(base, relative) {
350
+ if (!base || base === ".") return relative
351
+ return `${base}/${relative}`
352
+ }
353
+
354
+ /**
355
+ * Get the directory portion of a path.
356
+ *
357
+ * @param {string} filePath
358
+ * @returns {string}
359
+ */
360
+ function dirname(filePath) {
361
+ const lastSlash = filePath.lastIndexOf("/")
362
+ if (lastSlash === -1) return ""
363
+ return filePath.substring(0, lastSlash)
364
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * MCP Server Install Target
3
+ *
4
+ * Handles `lpm add` for packages with `type: "mcp-server"` in lpm.config.json.
5
+ *
6
+ * Instead of copying source files into the project, this handler:
7
+ * 1. Installs the package globally (so `npx` can find it, or node can run it)
8
+ * 2. Detects installed AI editors
9
+ * 3. Prompts for env vars defined in mcpConfig.env
10
+ * 4. Writes MCP server config entries to each editor
11
+ *
12
+ * @module cli/lib/install-targets/mcp-server
13
+ */
14
+
15
+ import * as p from "@clack/prompts"
16
+ import chalk from "chalk"
17
+ import {
18
+ addMcpServer,
19
+ detectEditors,
20
+ EDITORS,
21
+ hasMcpServer,
22
+ removeMcpServerEntry,
23
+ shortPath,
24
+ } from "../editors.js"
25
+
26
+ /**
27
+ * Derive the MCP server name from the package name.
28
+ * @lpm.dev/owner.my-mcp → "lpm-owner-my-mcp"
29
+ * @param {string} pkgName - Full package reference (e.g., "@lpm.dev/owner.my-mcp")
30
+ * @returns {string}
31
+ */
32
+ function deriveServerName(pkgName) {
33
+ // Strip @lpm.dev/ prefix and replace dots/slashes with dashes
34
+ return `lpm-${pkgName.replace("@lpm.dev/", "").replace(/[./]/g, "-")}`
35
+ }
36
+
37
+ /**
38
+ * Build the server config object from lpm.config.json's mcpConfig + user answers.
39
+ *
40
+ * @param {object} mcpConfig - mcpConfig from lpm.config.json
41
+ * @param {string} pkgName - Package name for npx fallback
42
+ * @param {object} envAnswers - User-provided env var values
43
+ * @returns {object} MCP server config for editor JSON
44
+ */
45
+ function buildServerConfig(mcpConfig, pkgName, envAnswers) {
46
+ const config = {}
47
+
48
+ if (mcpConfig?.command) {
49
+ config.command = mcpConfig.command
50
+ config.args = mcpConfig.args || []
51
+ } else {
52
+ // Fallback: use npx to run the package
53
+ config.command = "npx"
54
+ config.args = [pkgName]
55
+ }
56
+
57
+ // Add env vars (only non-empty values)
58
+ const env = {}
59
+ for (const [key, value] of Object.entries(envAnswers)) {
60
+ if (value) env[key] = value
61
+ }
62
+ if (Object.keys(env).length > 0) {
63
+ config.env = env
64
+ }
65
+
66
+ return config
67
+ }
68
+
69
+ /**
70
+ * Prompt user for env vars defined in mcpConfig.env.
71
+ *
72
+ * @param {object} envSchema - env field from mcpConfig (key → { prompt, required })
73
+ * @returns {Promise<object>} key → value answers
74
+ */
75
+ async function promptForEnvVars(envSchema) {
76
+ if (!envSchema || Object.keys(envSchema).length === 0) return {}
77
+
78
+ const answers = {}
79
+
80
+ for (const [key, schema] of Object.entries(envSchema)) {
81
+ const label =
82
+ typeof schema === "object" ? schema.prompt : `Enter value for ${key}`
83
+ const required = typeof schema === "object" ? schema.required : false
84
+
85
+ const value = await p.text({
86
+ message: label || `Enter value for ${key}`,
87
+ placeholder: required ? "(required)" : "(optional, press Enter to skip)",
88
+ validate: val => {
89
+ if (required && !val?.trim()) return `${key} is required`
90
+ },
91
+ })
92
+
93
+ if (p.isCancel(value)) {
94
+ p.cancel("Installation cancelled.")
95
+ process.exit(0)
96
+ }
97
+
98
+ if (value?.trim()) {
99
+ answers[key] = value.trim()
100
+ }
101
+ }
102
+
103
+ return answers
104
+ }
105
+
106
+ /**
107
+ * Install an MCP server package into the user's AI editors.
108
+ *
109
+ * Called by `lpm add` when the package has `type: "mcp-server"` in lpm.config.json.
110
+ *
111
+ * @param {object} params
112
+ * @param {string} params.name - Package name (e.g., "@lpm.dev/owner.my-mcp")
113
+ * @param {string} params.version - Package version
114
+ * @param {object} params.lpmConfig - Parsed lpm.config.json
115
+ * @param {string} params.extractDir - Temp directory with extracted package files
116
+ * @param {object} params.options - CLI options (force, yes)
117
+ * @returns {Promise<{ success: boolean, message: string }>}
118
+ */
119
+ export async function installMcpServer({
120
+ name,
121
+ version: _version,
122
+ lpmConfig,
123
+ extractDir: _extractDir,
124
+ options,
125
+ }) {
126
+ const mcpConfig = lpmConfig?.mcpConfig || {}
127
+ const serverName = deriveServerName(name)
128
+
129
+ // 1. Detect installed editors
130
+ const detected = detectEditors()
131
+
132
+ if (detected.length === 0) {
133
+ return {
134
+ success: false,
135
+ message:
136
+ "No supported AI editors detected. Install Claude Code, Cursor, VS Code, Claude Desktop, or Windsurf first.",
137
+ }
138
+ }
139
+
140
+ // 2. Prompt for env vars (unless --yes)
141
+ let envAnswers = {}
142
+ if (mcpConfig.env && !options?.yes) {
143
+ envAnswers = await promptForEnvVars(mcpConfig.env)
144
+ }
145
+
146
+ // 3. Build server config
147
+ const serverConfig = buildServerConfig(mcpConfig, name, envAnswers)
148
+
149
+ // 4. Let user select editors (unless --yes → all detected)
150
+ let selectedEditors = detected
151
+
152
+ if (!options?.yes && detected.length > 1) {
153
+ const selectOptions = detected.map(editor => {
154
+ const installed = hasMcpServer(
155
+ editor.globalPath,
156
+ editor.serverKey,
157
+ serverName,
158
+ )
159
+ return {
160
+ value: editor.id,
161
+ label: installed
162
+ ? `${editor.name} ${chalk.dim("(will update)")}`
163
+ : editor.name,
164
+ hint: shortPath(editor.globalPath),
165
+ }
166
+ })
167
+
168
+ const selected = await p.multiselect({
169
+ message: "Configure MCP server in:",
170
+ options: selectOptions,
171
+ initialValues: selectOptions.map(o => o.value),
172
+ required: true,
173
+ })
174
+
175
+ if (p.isCancel(selected)) {
176
+ p.cancel("Installation cancelled.")
177
+ process.exit(0)
178
+ }
179
+
180
+ selectedEditors = detected.filter(e => selected.includes(e.id))
181
+ }
182
+
183
+ // 5. Write config to each editor
184
+ let count = 0
185
+ const configured = []
186
+
187
+ for (const editor of selectedEditors) {
188
+ try {
189
+ addMcpServer(
190
+ editor.globalPath,
191
+ editor.serverKey,
192
+ serverName,
193
+ serverConfig,
194
+ )
195
+ configured.push(editor.name)
196
+ count++
197
+ } catch (err) {
198
+ console.error(
199
+ chalk.red(` Failed to configure ${editor.name}: ${err.message}`),
200
+ )
201
+ }
202
+ }
203
+
204
+ if (count === 0) {
205
+ return {
206
+ success: false,
207
+ message: "Failed to configure any editors.",
208
+ }
209
+ }
210
+
211
+ return {
212
+ success: true,
213
+ message: `MCP server configured in ${configured.join(", ")}. Restart your editors to activate.`,
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Remove an MCP server package from the user's AI editors.
219
+ *
220
+ * @param {object} params
221
+ * @param {string} params.name - Package name
222
+ * @returns {Promise<{ success: boolean, message: string }>}
223
+ */
224
+ export async function removeMcpServer({ name }) {
225
+ const serverName = deriveServerName(name)
226
+ let count = 0
227
+
228
+ for (const editor of EDITORS) {
229
+ if (removeMcpServerEntry(editor.globalPath, editor.serverKey, serverName)) {
230
+ count++
231
+ }
232
+ }
233
+
234
+ if (count === 0) {
235
+ return {
236
+ success: true,
237
+ message: "MCP server was not configured in any editor.",
238
+ }
239
+ }
240
+
241
+ return {
242
+ success: true,
243
+ message: `Removed MCP server from ${count} editor${count > 1 ? "s" : ""}. Restart your editors to apply.`,
244
+ }
245
+ }