@machinemetrics/mm-react-components 1.4.2 → 1.5.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/CHANGELOG.md +40 -0
- package/agent-docs/ai-agent-guide.md +13 -13
- package/agent-docs/chakra-migration-readme.md +7 -6
- package/agent-docs/chakra-migration-troubleshooting.md +8 -8
- package/agent-docs/component-mapping-reference.md +45 -9
- package/agent-docs/{cursor-skill-mm-carbide.md → cursor-skill-components.md} +18 -14
- package/dist/cursor-skill/components/SKILL.md +231 -0
- package/dist/cursor-skill/components/component-structure.md +33 -0
- package/dist/cursor-skill/components/reference.md +39 -0
- package/dist/cursor-skill/{mm-carbide → components}/widget-rules.md +16 -50
- package/dist/lib/mm-react-components.css +1 -1
- package/dist/preview/SearchInputPreview.d.ts +2 -0
- package/dist/preview/SearchInputPreview.d.ts.map +1 -0
- package/dist/preview/SimplePaginationPreview.d.ts +2 -0
- package/dist/preview/SimplePaginationPreview.d.ts.map +1 -0
- package/dist/preview/SpinnerCarbidePreview.d.ts +2 -0
- package/dist/preview/SpinnerCarbidePreview.d.ts.map +1 -0
- package/dist/scripts/init.cjs +3 -3
- package/dist/scripts/install-skill.cjs +425 -47
- package/dist/themes/carbide.css +20 -17
- package/package.json +6 -1
- package/dist/cursor-skill/mm-carbide/SKILL.md +0 -72
- package/dist/cursor-skill/mm-carbide/reference.md +0 -59
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SearchInputPreview.d.ts","sourceRoot":"","sources":["../../src/preview/SearchInputPreview.tsx"],"names":[],"mappings":"AAGA,MAAM,CAAC,OAAO,UAAU,kBAAkB,4CAwBzC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SimplePaginationPreview.d.ts","sourceRoot":"","sources":["../../src/preview/SimplePaginationPreview.tsx"],"names":[],"mappings":"AAIA,MAAM,CAAC,OAAO,UAAU,uBAAuB,4CAsC9C"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SpinnerCarbidePreview.d.ts","sourceRoot":"","sources":["../../src/preview/SpinnerCarbidePreview.tsx"],"names":[],"mappings":"AAGA,MAAM,CAAC,OAAO,UAAU,qBAAqB,4CAsC5C"}
|
package/dist/scripts/init.cjs
CHANGED
|
@@ -187,10 +187,10 @@ async function main() {
|
|
|
187
187
|
log('\nDocs: GETTING_STARTED.md, CSS_EXPORTS.md, DEPENDENCIES.md', 'reset');
|
|
188
188
|
log('Repo: https://github.com/machinemetrics/mm-react-components', 'reset');
|
|
189
189
|
|
|
190
|
-
// Offer
|
|
191
|
-
log('\nOptional:
|
|
190
|
+
// Offer components AI skill installation (mm-install-skill will prompt for target/scope)
|
|
191
|
+
log('\nOptional: components AI skill for Carbide-styled UI generation.', 'yellow');
|
|
192
192
|
const installSkillAnswer = await askQuestion(
|
|
193
|
-
' Install the
|
|
193
|
+
' Install the components skill for your AI editor? (y/N): ',
|
|
194
194
|
);
|
|
195
195
|
if (
|
|
196
196
|
installSkillAnswer.toLowerCase() === 'y' ||
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* MM React Components - Agent Skills installer (
|
|
4
|
+
* MM React Components - Agent Skills installer (components)
|
|
5
5
|
*
|
|
6
|
-
* Installs or updates the
|
|
6
|
+
* Installs or updates the components skill for any Agent Skills–compatible tool.
|
|
7
7
|
* Follows the Agent Skills open standard (agentskills.io). Default: Cursor (project).
|
|
8
8
|
*
|
|
9
9
|
* Usage: npx @machinemetrics/mm-react-components mm-install-skill [options]
|
|
@@ -12,20 +12,23 @@
|
|
|
12
12
|
* --scope=<scope> project (default) | user
|
|
13
13
|
* --force overwrite even when version matches
|
|
14
14
|
*
|
|
15
|
-
* If --target
|
|
16
|
-
* which AI tool and project vs user (global) install.
|
|
15
|
+
* If BOTH --target and --scope are omitted, the script prompts interactively
|
|
16
|
+
* for which AI tool and project vs user (global) install. When either flag is
|
|
17
|
+
* passed, the run is non-interactive and the omitted flag takes its default
|
|
18
|
+
* (target: cursor, scope: project).
|
|
17
19
|
*
|
|
18
20
|
* Paths by tool (project = cwd, user = home):
|
|
19
|
-
* cursor: .cursor/skills/
|
|
20
|
-
* copilot: .github/skills/
|
|
21
|
-
* claude: .claude/skills/
|
|
22
|
-
* gemini: .gemini/skills/
|
|
21
|
+
* cursor: .cursor/skills/components | ~/.cursor/skills/components
|
|
22
|
+
* copilot: .github/skills/components | ~/.copilot/skills/components (VS Code / GitHub Copilot)
|
|
23
|
+
* claude: .claude/skills/components | ~/.claude/skills/components (Claude Code)
|
|
24
|
+
* gemini: .gemini/skills/components | ~/.gemini/skills/components (Gemini CLI)
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
27
|
|
|
26
28
|
const fs = require('fs');
|
|
27
29
|
const path = require('path');
|
|
28
30
|
const os = require('os');
|
|
31
|
+
const crypto = require('crypto');
|
|
29
32
|
const readline = require('readline');
|
|
30
33
|
|
|
31
34
|
const { ASCII_LOGO } = require(path.join(__dirname, 'ascii-logo.cjs'));
|
|
@@ -48,11 +51,14 @@ function log(message, color = 'reset') {
|
|
|
48
51
|
console.log(`${colors[color]}${message}${colors.reset}`);
|
|
49
52
|
}
|
|
50
53
|
|
|
54
|
+
const SKILL_NAME = 'components';
|
|
55
|
+
const LEGACY_SKILL_NAME = 'mm-carbide';
|
|
56
|
+
|
|
51
57
|
const TARGET_PATHS = {
|
|
52
|
-
cursor: { project: '.cursor/skills/
|
|
53
|
-
copilot: { project: '.github/skills/
|
|
54
|
-
claude: { project: '.claude/skills/
|
|
55
|
-
gemini: { project: '.gemini/skills/
|
|
58
|
+
cursor: { project: '.cursor/skills/components', user: '.cursor/skills/components' },
|
|
59
|
+
copilot: { project: '.github/skills/components', user: '.copilot/skills/components' },
|
|
60
|
+
claude: { project: '.claude/skills/components', user: '.claude/skills/components' },
|
|
61
|
+
gemini: { project: '.gemini/skills/components', user: '.gemini/skills/components' },
|
|
56
62
|
};
|
|
57
63
|
|
|
58
64
|
function parseArg(name) {
|
|
@@ -74,26 +80,315 @@ function askQuestion(question) {
|
|
|
74
80
|
});
|
|
75
81
|
}
|
|
76
82
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
const MANAGED_MANIFEST_NAME = '.mm-skill-manifest.json';
|
|
84
|
+
const DIGEST_FILE_NAME = 'skill-digest.txt';
|
|
85
|
+
|
|
86
|
+
function sha256(content) {
|
|
87
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Recursively lists files under `dir`, relative paths with forward slashes,
|
|
91
|
+
// sorted — the shipped skill's file set.
|
|
92
|
+
function listShippedFiles(dir, prefix = '') {
|
|
93
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
94
|
+
const results = [];
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
97
|
+
if (entry.isSymbolicLink()) continue;
|
|
98
|
+
if (entry.isDirectory()) {
|
|
99
|
+
results.push(...listShippedFiles(path.join(dir, entry.name), rel));
|
|
100
|
+
} else if (entry.isFile()) {
|
|
101
|
+
results.push(rel);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return results.sort();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A single content digest over every shipped file (path + content), so
|
|
108
|
+
// "already up to date" reflects actual shipped content, not just the package
|
|
109
|
+
// version string (see VER-DEFER: skill content can change without a bump).
|
|
110
|
+
function computeSkillDigest(skillSource) {
|
|
111
|
+
const files = listShippedFiles(skillSource);
|
|
112
|
+
const hash = crypto.createHash('sha256');
|
|
113
|
+
for (const rel of files) {
|
|
114
|
+
hash.update(rel);
|
|
115
|
+
hash.update('\0');
|
|
116
|
+
hash.update(fs.readFileSync(path.join(skillSource, rel)));
|
|
117
|
+
hash.update('\0');
|
|
118
|
+
}
|
|
119
|
+
return hash.digest('hex');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildManagedManifest(skillSource) {
|
|
123
|
+
const files = listShippedFiles(skillSource);
|
|
124
|
+
const manifest = { files: {} };
|
|
125
|
+
for (const rel of files) {
|
|
126
|
+
manifest.files[rel] = sha256(fs.readFileSync(path.join(skillSource, rel)));
|
|
127
|
+
}
|
|
128
|
+
return manifest;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The manifest is self-identifying: it records the shipped version and the
|
|
132
|
+
// aggregate content digest it was written for, so a torn update (version.txt
|
|
133
|
+
// and skill-digest.txt renamed, manifest rename never happened) is detected
|
|
134
|
+
// as a mismatch and repaired instead of accepted as "up to date".
|
|
135
|
+
function manifestIdentityMatches(manifestStatus, packageVersion, currentDigest) {
|
|
136
|
+
return (
|
|
137
|
+
manifestStatus.version === packageVersion && manifestStatus.aggregateDigest === currentDigest
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A manifest key is only usable as a file path if it is a plain relative
|
|
142
|
+
// path that stays inside the skill directory: no absolute paths, no drive
|
|
143
|
+
// letters, no backslashes, no empty/./.. segments. Manifest content is
|
|
144
|
+
// untrusted input (users can edit or corrupt it), and keys are later joined
|
|
145
|
+
// to the destination for pruning.
|
|
146
|
+
function isSafeRelativePath(rel) {
|
|
147
|
+
if (typeof rel !== 'string' || rel.length === 0) return false;
|
|
148
|
+
if (rel.includes('\\') || rel.includes('\0')) return false;
|
|
149
|
+
if (path.isAbsolute(rel) || /^[A-Za-z]:/.test(rel)) return false;
|
|
150
|
+
return rel.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/i;
|
|
154
|
+
|
|
155
|
+
// Reads the managed-files manifest and reports whether it is present and
|
|
156
|
+
// trustworthy, distinguishing "no manifest yet" (legacy pre-manifest install)
|
|
157
|
+
// from "manifest exists but is corrupt or schema-invalid" so callers can warn
|
|
158
|
+
// appropriately. A manifest with any unsafe key or non-digest value is never
|
|
159
|
+
// partially trusted — it is treated exactly like an unreadable one.
|
|
160
|
+
function readManifestWithStatus(filePath) {
|
|
161
|
+
if (!fs.existsSync(filePath)) {
|
|
162
|
+
return { files: {}, present: false, corrupted: false, version: null, aggregateDigest: null };
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
166
|
+
const files = parsed && typeof parsed === 'object' ? parsed.files : null;
|
|
167
|
+
if (!files || typeof files !== 'object' || Array.isArray(files)) {
|
|
168
|
+
return { files: {}, present: true, corrupted: true, version: null, aggregateDigest: null };
|
|
169
|
+
}
|
|
170
|
+
for (const [rel, digest] of Object.entries(files)) {
|
|
171
|
+
if (!isSafeRelativePath(rel) || typeof digest !== 'string' || !SHA256_HEX.test(digest)) {
|
|
172
|
+
return { files: {}, present: true, corrupted: true, version: null, aggregateDigest: null };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Identity fields (absent on legacy manifests): kept only when they carry
|
|
176
|
+
// the expected shapes — anything else is treated as not-recorded.
|
|
177
|
+
const version = typeof parsed.version === 'string' ? parsed.version : null;
|
|
178
|
+
const aggregateDigest =
|
|
179
|
+
typeof parsed.aggregateDigest === 'string' && SHA256_HEX.test(parsed.aggregateDigest)
|
|
180
|
+
? parsed.aggregateDigest
|
|
181
|
+
: null;
|
|
182
|
+
return { files, present: true, corrupted: false, version, aggregateDigest };
|
|
183
|
+
} catch {
|
|
184
|
+
return { files: {}, present: true, corrupted: true, version: null, aggregateDigest: null };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// True when `targetPath` resolves strictly beneath `baseDir` — the guard every
|
|
189
|
+
// prune/write target must pass before any fs mutation.
|
|
190
|
+
function isWithin(baseDir, targetPath) {
|
|
191
|
+
const rel = path.relative(baseDir, targetPath);
|
|
192
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Existence check that does NOT follow symlinks: a dangling symlink counts as
|
|
196
|
+
// occupied, so nothing is ever written through it.
|
|
197
|
+
function existsNoFollow(p) {
|
|
198
|
+
try {
|
|
199
|
+
fs.lstatSync(p);
|
|
200
|
+
return true;
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
83
205
|
|
|
206
|
+
// Lexical containment (isWithin) is not enough on its own: a symlinked file
|
|
207
|
+
// or subdirectory inside the destination would route reads/writes/deletes
|
|
208
|
+
// outside it. Walk every existing component of `targetPath` beneath the
|
|
209
|
+
// (already realpath-resolved) destination and refuse if any is a symlink.
|
|
210
|
+
function assertNoSymlinkedComponents(realBase, targetPath) {
|
|
211
|
+
const rel = path.relative(realBase, targetPath);
|
|
212
|
+
let cur = realBase;
|
|
213
|
+
for (const segment of rel.split(path.sep)) {
|
|
214
|
+
cur = path.join(cur, segment);
|
|
215
|
+
let stat;
|
|
216
|
+
try {
|
|
217
|
+
stat = fs.lstatSync(cur);
|
|
218
|
+
} catch {
|
|
219
|
+
return; // nothing exists from here down: nothing to follow
|
|
220
|
+
}
|
|
84
221
|
if (stat.isSymbolicLink()) {
|
|
85
|
-
|
|
86
|
-
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Refusing to operate through a symlink inside the skill directory: ${path.relative(realBase, cur)}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Returns a backup path for `destPath` that does not collide with an
|
|
230
|
+
// existing backup: <path>.bak, then <path>.bak.1, <path>.bak.2, ...
|
|
231
|
+
function nextBackupPath(destPath) {
|
|
232
|
+
// lstat-based existence: a dangling symlink squatting on a backup name is
|
|
233
|
+
// treated as occupied instead of being written through.
|
|
234
|
+
const base = `${destPath}.bak`;
|
|
235
|
+
if (!existsNoFollow(base)) return base;
|
|
236
|
+
let i = 1;
|
|
237
|
+
let candidate = `${base}.${i}`;
|
|
238
|
+
while (existsNoFollow(candidate)) {
|
|
239
|
+
i += 1;
|
|
240
|
+
candidate = `${base}.${i}`;
|
|
241
|
+
}
|
|
242
|
+
return candidate;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Atomic metadata write that cannot be redirected by a pre-planted symlink:
|
|
246
|
+
// the temp name carries a random suffix and is opened with exclusive
|
|
247
|
+
// semantics ('wx'), so an existing entry — symlink included — fails EEXIST
|
|
248
|
+
// and a fresh suffix is tried instead of writing through it.
|
|
249
|
+
function writeFileAtomic(filePath, content, { suffixes } = {}) {
|
|
250
|
+
const iterator = suffixes ? suffixes[Symbol.iterator]() : null;
|
|
251
|
+
const nextSuffix = () => {
|
|
252
|
+
if (!iterator) return crypto.randomBytes(8).toString('hex');
|
|
253
|
+
const n = iterator.next();
|
|
254
|
+
if (n.done) throw new Error(`Could not create a temp file for ${filePath}`);
|
|
255
|
+
return n.value;
|
|
256
|
+
};
|
|
257
|
+
for (;;) {
|
|
258
|
+
const tmpPath = `${filePath}.tmp-${nextSuffix()}`;
|
|
259
|
+
try {
|
|
260
|
+
fs.writeFileSync(tmpPath, content, { encoding: 'utf8', flag: 'wx' });
|
|
261
|
+
fs.renameSync(tmpPath, filePath);
|
|
262
|
+
return;
|
|
263
|
+
} catch (err) {
|
|
264
|
+
if (err.code !== 'EEXIST') throw err;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Updates `destination` from `skillSource`, using the previous managed-files
|
|
270
|
+
// manifest (if any) to:
|
|
271
|
+
// - back up a locally modified managed file to <name>.bak (with a warning)
|
|
272
|
+
// before overwriting it with fresh shipped content;
|
|
273
|
+
// - prune managed files the current skill source no longer ships;
|
|
274
|
+
// - leave any file not present in the previous manifest (i.e. not
|
|
275
|
+
// installer-managed — user-added) completely untouched.
|
|
276
|
+
// Returns the new managed-files manifest to write after this call.
|
|
277
|
+
// `destination` must already be realpath-resolved by the caller so symlink
|
|
278
|
+
// checks compare against the real directory.
|
|
279
|
+
function syncManagedFiles(skillSource, destination) {
|
|
280
|
+
const shippedFiles = listShippedFiles(skillSource);
|
|
281
|
+
|
|
282
|
+
// Pre-pass, before ANY mutation: every write target must be lexically
|
|
283
|
+
// contained AND free of symlinked components, so a refusal leaves no
|
|
284
|
+
// partial work behind.
|
|
285
|
+
for (const rel of shippedFiles) {
|
|
286
|
+
const destPath = path.join(destination, ...rel.split('/'));
|
|
287
|
+
if (!isWithin(destination, destPath)) {
|
|
288
|
+
throw new Error(`Refusing to write outside the skill directory: ${rel}`);
|
|
289
|
+
}
|
|
290
|
+
assertNoSymlinkedComponents(destination, destPath);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const manifestStatus = readManifestWithStatus(path.join(destination, MANAGED_MANIFEST_NAME));
|
|
294
|
+
const previousFiles = manifestStatus.files;
|
|
295
|
+
// The manifest is only trustworthy for pruning decisions when it exists
|
|
296
|
+
// and parsed cleanly. A missing manifest (legacy install) is a normal,
|
|
297
|
+
// unremarkable case; a present-but-corrupt manifest is not, so it gets a
|
|
298
|
+
// notice below.
|
|
299
|
+
const manifestTrustworthy = manifestStatus.present && !manifestStatus.corrupted;
|
|
300
|
+
|
|
301
|
+
if (manifestStatus.corrupted) {
|
|
302
|
+
log(
|
|
303
|
+
'Managed-files manifest unreadable; proceeding with byte-comparison fallback for local modification detection.',
|
|
304
|
+
'yellow',
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
for (const rel of shippedFiles) {
|
|
309
|
+
const srcPath = path.join(skillSource, rel);
|
|
310
|
+
const destPath = path.join(destination, ...rel.split('/'));
|
|
311
|
+
if (!isWithin(destination, destPath)) {
|
|
312
|
+
throw new Error(`Refusing to write outside the skill directory: ${rel}`);
|
|
87
313
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
314
|
+
const destParent = path.dirname(destPath);
|
|
315
|
+
if (!fs.existsSync(destParent)) {
|
|
316
|
+
fs.mkdirSync(destParent, { recursive: true });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (fs.existsSync(destPath)) {
|
|
320
|
+
let userModified;
|
|
321
|
+
if (Object.prototype.hasOwnProperty.call(previousFiles, rel)) {
|
|
322
|
+
userModified = sha256(fs.readFileSync(destPath)) !== previousFiles[rel];
|
|
323
|
+
} else {
|
|
324
|
+
// No manifest entry for this managed file — either a pre-manifest
|
|
325
|
+
// (legacy) install, or the manifest is missing/corrupted. Fall back
|
|
326
|
+
// to comparing against the shipped bytes: a mismatch is treated as
|
|
327
|
+
// a user modification (conservative false-positive backups are
|
|
328
|
+
// acceptable; silent overwrites are not).
|
|
329
|
+
userModified = sha256(fs.readFileSync(destPath)) !== sha256(fs.readFileSync(srcPath));
|
|
330
|
+
}
|
|
331
|
+
if (userModified) {
|
|
332
|
+
const backupPath = nextBackupPath(destPath);
|
|
333
|
+
fs.copyFileSync(destPath, backupPath);
|
|
334
|
+
log(
|
|
335
|
+
`Local modification to ${rel} detected; backed it up to ${path.basename(backupPath)} and overwrote it with the updated skill content.`,
|
|
336
|
+
'yellow',
|
|
337
|
+
);
|
|
91
338
|
}
|
|
92
|
-
copyRecursive(srcPath, destPath);
|
|
93
|
-
} else {
|
|
94
|
-
fs.copyFileSync(srcPath, destPath);
|
|
95
339
|
}
|
|
340
|
+
|
|
341
|
+
fs.copyFileSync(srcPath, destPath);
|
|
96
342
|
}
|
|
343
|
+
|
|
344
|
+
// Prune managed files the current skill no longer ships. A file only
|
|
345
|
+
// counts as "managed" if the previous manifest recorded it — anything
|
|
346
|
+
// absent from that manifest (user-added) is left alone. Pruning never
|
|
347
|
+
// runs off an untrustworthy manifest (missing, corrupted, or
|
|
348
|
+
// schema-invalid), never reaches outside the skill directory, and never
|
|
349
|
+
// deletes a file whose current bytes no longer match the recorded digest
|
|
350
|
+
// (user modifications are kept in place with a notice).
|
|
351
|
+
if (manifestTrustworthy) {
|
|
352
|
+
const shippedSet = new Set(shippedFiles);
|
|
353
|
+
for (const rel of Object.keys(previousFiles)) {
|
|
354
|
+
if (shippedSet.has(rel)) continue;
|
|
355
|
+
const stalePath = path.join(destination, ...rel.split('/'));
|
|
356
|
+
if (!isWithin(destination, stalePath)) continue;
|
|
357
|
+
try {
|
|
358
|
+
assertNoSymlinkedComponents(destination, stalePath);
|
|
359
|
+
} catch {
|
|
360
|
+
log(`Left ${rel} in place: it resolves through a symlink and was not touched.`, 'yellow');
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (!fs.existsSync(stalePath)) continue;
|
|
364
|
+
if (sha256(fs.readFileSync(stalePath)) === previousFiles[rel]) {
|
|
365
|
+
fs.rmSync(stalePath, { force: true });
|
|
366
|
+
log(`Removed ${rel}: no longer shipped with this skill.`, 'yellow');
|
|
367
|
+
} else {
|
|
368
|
+
log(
|
|
369
|
+
`Left ${rel} in place: no longer shipped with this skill, but it has local modifications.`,
|
|
370
|
+
'yellow',
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return buildManagedManifest(skillSource);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function warnOnManagedLegacyInstall(destination) {
|
|
380
|
+
// A managed legacy install is identified by the version.txt this installer
|
|
381
|
+
// used to write. User-authored directories (no version.txt) are ignored.
|
|
382
|
+
const legacyDir = path.join(path.dirname(destination), LEGACY_SKILL_NAME);
|
|
383
|
+
if (!fs.existsSync(path.join(legacyDir, 'version.txt'))) return;
|
|
384
|
+
log(
|
|
385
|
+
`Found a legacy managed ${LEGACY_SKILL_NAME} install at ${legacyDir}.`,
|
|
386
|
+
'yellow',
|
|
387
|
+
);
|
|
388
|
+
log(
|
|
389
|
+
`This skill is now installed as "${SKILL_NAME}". No files were deleted; remove the old ${LEGACY_SKILL_NAME} directory manually once you no longer need it.`,
|
|
390
|
+
'yellow',
|
|
391
|
+
);
|
|
97
392
|
}
|
|
98
393
|
|
|
99
394
|
function performInstall(target, scope, force) {
|
|
@@ -101,7 +396,18 @@ function performInstall(target, scope, force) {
|
|
|
101
396
|
const relativePath = TARGET_PATHS[target][scope];
|
|
102
397
|
const destination = path.join(baseDir, ...relativePath.split('/'));
|
|
103
398
|
|
|
104
|
-
|
|
399
|
+
warnOnManagedLegacyInstall(destination);
|
|
400
|
+
|
|
401
|
+
// Resolve the destination through any symlinks up front (creating it if
|
|
402
|
+
// needed): every later containment/symlink check compares against the real
|
|
403
|
+
// directory, so a destination that IS a symlink is resolved and still
|
|
404
|
+
// contained.
|
|
405
|
+
if (!fs.existsSync(destination)) {
|
|
406
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
407
|
+
}
|
|
408
|
+
const realDestination = fs.realpathSync(destination);
|
|
409
|
+
|
|
410
|
+
const skillSource = path.join(__dirname, '..', 'cursor-skill', SKILL_NAME);
|
|
105
411
|
// When installed: dist/scripts -> ../../package.json. When in repo: scripts -> ../package.json.
|
|
106
412
|
const packageJsonPath =
|
|
107
413
|
path.join(__dirname, '..', '..', 'package.json');
|
|
@@ -126,41 +432,105 @@ function performInstall(target, scope, force) {
|
|
|
126
432
|
process.exit(1);
|
|
127
433
|
}
|
|
128
434
|
|
|
129
|
-
const versionFilePath = path.join(
|
|
435
|
+
const versionFilePath = path.join(realDestination, 'version.txt');
|
|
436
|
+
const digestFilePath = path.join(realDestination, DIGEST_FILE_NAME);
|
|
130
437
|
let installedVersion = null;
|
|
131
|
-
|
|
438
|
+
let installedDigest = null;
|
|
439
|
+
if (fs.existsSync(versionFilePath)) {
|
|
132
440
|
try {
|
|
133
441
|
installedVersion = fs.readFileSync(versionFilePath, 'utf8').trim();
|
|
134
442
|
} catch {
|
|
135
443
|
installedVersion = null;
|
|
136
444
|
}
|
|
137
445
|
}
|
|
446
|
+
if (fs.existsSync(digestFilePath)) {
|
|
447
|
+
try {
|
|
448
|
+
installedDigest = fs.readFileSync(digestFilePath, 'utf8').trim();
|
|
449
|
+
} catch {
|
|
450
|
+
installedDigest = null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const currentDigest = computeSkillDigest(skillSource);
|
|
455
|
+
|
|
456
|
+
// Ownership of the destination is established only by a present, valid
|
|
457
|
+
// manifest — the same authority pruning relies on. A torn install (version
|
|
458
|
+
// and digest written, manifest missing or corrupt) is not owned and not up
|
|
459
|
+
// to date.
|
|
460
|
+
const manifestFilePath = path.join(realDestination, MANAGED_MANIFEST_NAME);
|
|
461
|
+
const preManifestStatus = readManifestWithStatus(manifestFilePath);
|
|
462
|
+
const ownershipEstablished = preManifestStatus.present && !preManifestStatus.corrupted;
|
|
138
463
|
|
|
139
|
-
|
|
140
|
-
|
|
464
|
+
// "Already up to date" requires the version, the shipped-content digest,
|
|
465
|
+
// AND a valid manifest whose own recorded identity matches. A
|
|
466
|
+
// skill-content-only change (no version bump) still reinstalls, since the
|
|
467
|
+
// digest catches what the version string can't; a torn metadata state (no
|
|
468
|
+
// valid manifest, or a manifest still carrying the previous update's
|
|
469
|
+
// identity) repairs instead of early-returning. Legacy manifests without
|
|
470
|
+
// identity fields keep the previous behavior, with a notice.
|
|
471
|
+
const manifestHasIdentity =
|
|
472
|
+
preManifestStatus.version !== null && preManifestStatus.aggregateDigest !== null;
|
|
473
|
+
const manifestIdentityOk = manifestHasIdentity
|
|
474
|
+
? manifestIdentityMatches(preManifestStatus, packageVersion, currentDigest)
|
|
475
|
+
: true;
|
|
476
|
+
if (!force && ownershipEstablished && manifestIdentityOk && installedVersion !== null && installedVersion === packageVersion && installedDigest === currentDigest) {
|
|
477
|
+
if (!manifestHasIdentity) {
|
|
478
|
+
log(
|
|
479
|
+
'Manifest was written by an older installer (no recorded version/digest); run with --force to refresh it.',
|
|
480
|
+
'yellow',
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
log(`Already up to date (${SKILL_NAME} v${packageVersion}).`, 'green');
|
|
141
484
|
return;
|
|
142
485
|
}
|
|
143
486
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (!fs.existsSync(destination)) {
|
|
149
|
-
fs.mkdirSync(destination, { recursive: true });
|
|
487
|
+
// Guard the metadata targets BEFORE any file is written so a refusal
|
|
488
|
+
// leaves no partial work (syncManagedFiles pre-checks its own targets).
|
|
489
|
+
for (const metadataPath of [versionFilePath, digestFilePath, manifestFilePath]) {
|
|
490
|
+
assertNoSymlinkedComponents(realDestination, metadataPath);
|
|
150
491
|
}
|
|
151
492
|
|
|
493
|
+
const managedManifest = {
|
|
494
|
+
version: packageVersion,
|
|
495
|
+
aggregateDigest: currentDigest,
|
|
496
|
+
...syncManagedFiles(skillSource, realDestination),
|
|
497
|
+
};
|
|
498
|
+
|
|
152
499
|
if (installedVersion !== null) {
|
|
153
|
-
copyRecursive(skillSource, destination);
|
|
154
500
|
log(
|
|
155
|
-
`Updated
|
|
501
|
+
`Updated ${SKILL_NAME} from ${installedVersion ?? 'unknown'} to v${packageVersion}.`,
|
|
156
502
|
'green',
|
|
157
503
|
);
|
|
158
504
|
} else {
|
|
159
|
-
|
|
160
|
-
log(`Installed mm-carbide v${packageVersion}.`, 'green');
|
|
505
|
+
log(`Installed ${SKILL_NAME} v${packageVersion}.`, 'green');
|
|
161
506
|
}
|
|
162
507
|
|
|
163
|
-
|
|
508
|
+
// Metadata is written via temp-file + rename (atomic on the same
|
|
509
|
+
// filesystem), with the manifest LAST: a kill mid-update can leave a stale
|
|
510
|
+
// version/digest, but never a valid manifest over torn content — and the
|
|
511
|
+
// up-to-date check above requires the manifest, so a torn state repairs on
|
|
512
|
+
// the next run. Until ownership is established (valid manifest present),
|
|
513
|
+
// any user-authored file squatting on a metadata name is backed up first.
|
|
514
|
+
const metadataWrites = [
|
|
515
|
+
[versionFilePath, packageVersion],
|
|
516
|
+
[digestFilePath, currentDigest],
|
|
517
|
+
[manifestFilePath, JSON.stringify(managedManifest, null, 2)],
|
|
518
|
+
];
|
|
519
|
+
for (const [filePath, content] of metadataWrites) {
|
|
520
|
+
if (
|
|
521
|
+
!ownershipEstablished &&
|
|
522
|
+
fs.existsSync(filePath) &&
|
|
523
|
+
fs.readFileSync(filePath, 'utf8') !== content
|
|
524
|
+
) {
|
|
525
|
+
const backupPath = nextBackupPath(filePath);
|
|
526
|
+
fs.copyFileSync(filePath, backupPath);
|
|
527
|
+
log(
|
|
528
|
+
`Existing ${path.basename(filePath)} was not written by this installer; backed it up to ${path.basename(backupPath)}.`,
|
|
529
|
+
'yellow',
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
writeFileAtomic(filePath, content);
|
|
533
|
+
}
|
|
164
534
|
|
|
165
535
|
log(`Location: ${destination}`, 'cyan');
|
|
166
536
|
log('Use when generating UIs with Carbide / mm-react-components.', 'cyan');
|
|
@@ -173,7 +543,11 @@ async function main() {
|
|
|
173
543
|
let target = parseArg('target');
|
|
174
544
|
let scope = parseArg('scope');
|
|
175
545
|
|
|
176
|
-
|
|
546
|
+
// Prompt only when NO flags were passed: any explicit flag signals a
|
|
547
|
+
// (possibly non-interactive) scripted caller, so the omitted flag falls
|
|
548
|
+
// back to its default instead of a prompt that would hang or overwrite
|
|
549
|
+
// the explicitly passed value.
|
|
550
|
+
const interactive = target == null && scope == null;
|
|
177
551
|
if (interactive) {
|
|
178
552
|
const targetPrompt =
|
|
179
553
|
'Which AI do you use? (1) Cursor (2) VS Code / GitHub Copilot (3) Claude Code (4) Gemini CLI: ';
|
|
@@ -221,7 +595,11 @@ async function main() {
|
|
|
221
595
|
performInstall(target, scope, force);
|
|
222
596
|
}
|
|
223
597
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
598
|
+
if (require.main === module) {
|
|
599
|
+
main().catch((err) => {
|
|
600
|
+
log(`Error: ${err.message}`, 'red');
|
|
601
|
+
process.exit(1);
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
module.exports = { syncManagedFiles, writeFileAtomic };
|
package/dist/themes/carbide.css
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/* Note: Font imports are now handled in src/lib/styles.css to avoid @import ordering issues */
|
|
2
2
|
|
|
3
3
|
:root {
|
|
4
|
-
--radius: 0.5rem;
|
|
5
|
-
--radius-sm: 4px;
|
|
6
|
-
--radius-md: calc(var(--radius) - 2px);
|
|
7
|
-
--radius-lg: var(--radius);
|
|
8
|
-
--radius-xl: calc(var(--radius) + 4px);
|
|
4
|
+
--radius: 0.5rem; /* parity-exempt: theme-invariant radius scale */
|
|
5
|
+
--radius-sm: 4px; /* parity-exempt: theme-invariant radius scale */
|
|
6
|
+
--radius-md: calc(var(--radius) - 2px); /* parity-exempt: theme-invariant radius scale */
|
|
7
|
+
--radius-lg: var(--radius); /* parity-exempt: theme-invariant radius scale */
|
|
8
|
+
--radius-xl: calc(var(--radius) + 4px); /* parity-exempt: theme-invariant radius scale */
|
|
9
9
|
--background: oklch(1 0 0);
|
|
10
10
|
--foreground: oklch(0.145 0 0);
|
|
11
11
|
--card: oklch(1 0 0);
|
|
@@ -35,32 +35,35 @@
|
|
|
35
35
|
--sidebar-ring: oklch(0.708 0 0);
|
|
36
36
|
|
|
37
37
|
/* Overlay token (base) */
|
|
38
|
+
/* parity-exempt: theme-invariant base — dark handling lives in consumer rules via color-mix */
|
|
38
39
|
--overlay-bg: rgba(0, 0, 0, 0.8);
|
|
39
40
|
|
|
40
41
|
/* --- Typography tokens (shadcn/ui reference) --- */
|
|
42
|
+
/* parity-exempt: theme-invariant font stack */
|
|
41
43
|
--font-sans:
|
|
42
44
|
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
|
43
45
|
Roboto, 'Helvetica Neue', Arial, 'Noto Sans Variable', sans-serif,
|
|
44
46
|
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
|
47
|
+
/* parity-exempt: theme-invariant font stack */
|
|
45
48
|
--font-mono:
|
|
46
49
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
|
47
50
|
'Courier New', monospace;
|
|
48
|
-
--font-heading: var(--font-sans);
|
|
49
|
-
--font-body: var(--font-sans);
|
|
51
|
+
--font-heading: var(--font-sans); /* parity-exempt: theme-invariant font stack */
|
|
52
|
+
--font-body: var(--font-sans); /* parity-exempt: theme-invariant font stack */
|
|
50
53
|
|
|
51
|
-
--text-base-size: 1rem; /* 16px */
|
|
52
|
-
--text-sm-size: 0.875rem; /* 14px */
|
|
53
|
-
--text-lg-size: 1.125rem; /* 18px */
|
|
54
|
+
--text-base-size: 1rem; /* 16px */ /* parity-exempt: theme-invariant type scale */
|
|
55
|
+
--text-sm-size: 0.875rem; /* 14px */ /* parity-exempt: theme-invariant type scale */
|
|
56
|
+
--text-lg-size: 1.125rem; /* 18px */ /* parity-exempt: theme-invariant type scale */
|
|
54
57
|
|
|
55
|
-
--font-weight-normal: 400;
|
|
56
|
-
--font-weight-medium: 500;
|
|
57
|
-
--font-weight-bold: 700;
|
|
58
|
+
--font-weight-normal: 400; /* parity-exempt: theme-invariant weight scale */
|
|
59
|
+
--font-weight-medium: 500; /* parity-exempt: theme-invariant weight scale */
|
|
60
|
+
--font-weight-bold: 700; /* parity-exempt: theme-invariant weight scale */
|
|
58
61
|
|
|
59
|
-
--leading-tight: 1.25;
|
|
60
|
-
--leading-normal: 1.5;
|
|
62
|
+
--leading-tight: 1.25; /* parity-exempt: theme-invariant line-height scale */
|
|
63
|
+
--leading-normal: 1.5; /* parity-exempt: theme-invariant line-height scale */
|
|
61
64
|
|
|
62
|
-
--tracking-tight: -0.015em;
|
|
63
|
-
--tracking-normal: 0;
|
|
65
|
+
--tracking-tight: -0.015em; /* parity-exempt: theme-invariant tracking scale */
|
|
66
|
+
--tracking-normal: 0; /* parity-exempt: theme-invariant tracking scale */
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
.dark {
|