@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.
@@ -0,0 +1,2 @@
1
+ export default function SearchInputPreview(): import("react/jsx-runtime").JSX.Element;
2
+ //# sourceMappingURL=SearchInputPreview.d.ts.map
@@ -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,2 @@
1
+ export default function SimplePaginationPreview(): import("react/jsx-runtime").JSX.Element;
2
+ //# sourceMappingURL=SimplePaginationPreview.d.ts.map
@@ -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,2 @@
1
+ export default function SpinnerCarbidePreview(): import("react/jsx-runtime").JSX.Element;
2
+ //# sourceMappingURL=SpinnerCarbidePreview.d.ts.map
@@ -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"}
@@ -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 Carbide AI skill installation (mm-install-skill will prompt for target/scope)
191
- log('\nOptional: mm-carbide AI skill for Carbide-styled UI generation.', 'yellow');
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 Carbide skill for your AI editor? (y/N): ',
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 (mm-carbide)
4
+ * MM React Components - Agent Skills installer (components)
5
5
  *
6
- * Installs or updates the mm-carbide skill for any Agent Skills–compatible tool.
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 or --scope are omitted, the script prompts interactively for
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/mm-carbide | ~/.cursor/skills/mm-carbide
20
- * copilot: .github/skills/mm-carbide | ~/.copilot/skills/mm-carbide (VS Code / GitHub Copilot)
21
- * claude: .claude/skills/mm-carbide | ~/.claude/skills/mm-carbide (Claude Code)
22
- * gemini: .gemini/skills/mm-carbide | ~/.gemini/skills/mm-carbide (Gemini CLI)
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/mm-carbide', user: '.cursor/skills/mm-carbide' },
53
- copilot: { project: '.github/skills/mm-carbide', user: '.copilot/skills/mm-carbide' },
54
- claude: { project: '.claude/skills/mm-carbide', user: '.claude/skills/mm-carbide' },
55
- gemini: { project: '.gemini/skills/mm-carbide', user: '.gemini/skills/mm-carbide' },
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
- function copyRecursive(src, dest) {
78
- const items = fs.readdirSync(src);
79
- for (const item of items) {
80
- const srcPath = path.join(src, item);
81
- const destPath = path.join(dest, item);
82
- const stat = fs.lstatSync(srcPath);
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
- // Do not follow symlinks (defense-in-depth)
86
- continue;
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
- if (stat.isDirectory()) {
89
- if (!fs.existsSync(destPath)) {
90
- fs.mkdirSync(destPath, { recursive: true });
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
- const skillSource = path.join(__dirname, '..', 'cursor-skill', 'mm-carbide');
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(destination, 'version.txt');
435
+ const versionFilePath = path.join(realDestination, 'version.txt');
436
+ const digestFilePath = path.join(realDestination, DIGEST_FILE_NAME);
130
437
  let installedVersion = null;
131
- if (fs.existsSync(destination) && fs.existsSync(versionFilePath)) {
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
- if (!force && installedVersion !== null && installedVersion === packageVersion) {
140
- log(`Already up to date (mm-carbide v${packageVersion}).`, 'green');
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
- const destParent = path.dirname(destination);
145
- if (!fs.existsSync(destParent)) {
146
- fs.mkdirSync(destParent, { recursive: true });
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 mm-carbide from ${installedVersion ?? 'unknown'} to v${packageVersion}.`,
501
+ `Updated ${SKILL_NAME} from ${installedVersion ?? 'unknown'} to v${packageVersion}.`,
156
502
  'green',
157
503
  );
158
504
  } else {
159
- copyRecursive(skillSource, destination);
160
- log(`Installed mm-carbide v${packageVersion}.`, 'green');
505
+ log(`Installed ${SKILL_NAME} v${packageVersion}.`, 'green');
161
506
  }
162
507
 
163
- fs.writeFileSync(versionFilePath, packageVersion, 'utf8');
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
- const interactive = target == null || scope == null;
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
- main().catch((err) => {
225
- log(`Error: ${err.message}`, 'red');
226
- process.exit(1);
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 };
@@ -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 {