@iamsaroj/replicax 0.0.3 → 0.0.5

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 (3) hide show
  1. package/README.md +199 -73
  2. package/dist/index.js +1590 -205
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -60,21 +60,24 @@ var logger = {
60
60
  };
61
61
 
62
62
  // src/commands/init.ts
63
- import path6 from "path";
64
- import fs5 from "fs-extra";
63
+ import path7 from "path";
64
+ import fs6 from "fs-extra";
65
65
  import ora from "ora";
66
66
  import { confirm } from "@inquirer/prompts";
67
67
 
68
68
  // src/constants.ts
69
69
  var REPLICAX_DIR = ".replicax";
70
70
  var IGNORE_FILE = ".replicaxignore";
71
- var REPLICAX_VERSION = "2.0.0";
71
+ var INCLUDE_FILE = ".replicaxinclude";
72
+ var ROOT_SKILL_FILE = "SKILL.md";
73
+ var REPLICAX_VERSION = "2.2.0";
72
74
  var PROFILE_FILES = {
73
75
  profile: "profile.json",
74
76
  tooling: "tooling.json",
75
77
  structure: "structure.json",
76
78
  metadata: "metadata.json",
77
- checksum: "checksum.json"
79
+ checksum: "checksum.json",
80
+ manifest: "manifest.json"
78
81
  };
79
82
  var SCAN_PRUNE_GLOBS = [
80
83
  "**/node_modules/**",
@@ -99,6 +102,7 @@ var SCAN_PRUNE_GLOBS = [
99
102
  "**/.fleet/**",
100
103
  "**/.zed/**"
101
104
  ];
105
+ var INCLUDE_PRUNE_GLOBS = ["**/node_modules/**", "**/.git/**", `**/${REPLICAX_DIR}/**`];
102
106
  var DEFAULT_IGNORE_PATTERNS = [
103
107
  "node_modules/",
104
108
  ".git/",
@@ -181,9 +185,9 @@ yarn-error.log*
181
185
  `;
182
186
 
183
187
  // src/core/scanner.ts
184
- import path4 from "path";
185
- import fs3 from "fs-extra";
186
- import fg from "fast-glob";
188
+ import path5 from "path";
189
+ import fs4 from "fs-extra";
190
+ import fg2 from "fast-glob";
187
191
 
188
192
  // src/config/supported-files.ts
189
193
  var CONFIG_CATEGORIES = [
@@ -288,6 +292,38 @@ var CONFIG_CATEGORIES = [
288
292
  label: "Git Hooks",
289
293
  patterns: [".husky/*"]
290
294
  },
295
+ {
296
+ id: "jvm-build",
297
+ label: "JVM Build",
298
+ // The captured surface is language-agnostic: Maven/Gradle build files are
299
+ // setup, not application code. Globbed with `**/` so monorepos (a Spring
300
+ // backend beside a JS frontend) are captured too. The gradle wrapper JAR is
301
+ // binary and deliberately excluded — only its text `.properties` is kept.
302
+ patterns: [
303
+ "**/pom.xml",
304
+ "**/build.gradle",
305
+ "**/build.gradle.kts",
306
+ "**/settings.gradle",
307
+ "**/settings.gradle.kts",
308
+ "gradle.properties",
309
+ "mvnw",
310
+ "mvnw.cmd",
311
+ "gradlew",
312
+ "gradlew.bat",
313
+ "**/gradle/wrapper/gradle-wrapper.properties"
314
+ ]
315
+ },
316
+ {
317
+ id: "jvm-config",
318
+ label: "JVM Config",
319
+ // Spring-style externalized config. Scoped to `src/main/resources/` so we
320
+ // capture application config without sweeping up unrelated `.properties`.
321
+ patterns: [
322
+ "**/src/main/resources/application*.yml",
323
+ "**/src/main/resources/application*.yaml",
324
+ "**/src/main/resources/application*.properties"
325
+ ]
326
+ },
291
327
  {
292
328
  id: "misc",
293
329
  label: "Miscellaneous Tooling",
@@ -308,6 +344,13 @@ var CONFIG_CATEGORIES = [
308
344
  ];
309
345
  var ALL_CONFIG_PATTERNS = CONFIG_CATEGORIES.flatMap((c) => c.patterns);
310
346
  var CATEGORY_BY_ID = new Map(CONFIG_CATEGORIES.map((c) => [c.id, c]));
347
+ var EXTRA_CATEGORY_LABELS = {
348
+ // Files pulled in explicitly via `.replicaxinclude`.
349
+ included: "Included files"
350
+ };
351
+ function categoryLabel(id) {
352
+ return CATEGORY_BY_ID.get(id)?.label ?? EXTRA_CATEGORY_LABELS[id] ?? id;
353
+ }
311
354
 
312
355
  // src/utils/paths.ts
313
356
  import path from "path";
@@ -353,6 +396,7 @@ import fs from "fs-extra";
353
396
  import ignore from "ignore";
354
397
  var IgnoreEngine = class _IgnoreEngine {
355
398
  ig;
399
+ userIg;
356
400
  secrets;
357
401
  userPatterns;
358
402
  constructor(userPatterns = []) {
@@ -361,6 +405,7 @@ var IgnoreEngine = class _IgnoreEngine {
361
405
  return t.length > 0 && !t.startsWith("#");
362
406
  });
363
407
  this.ig = ignore().add(DEFAULT_IGNORE_PATTERNS).add(this.userPatterns);
408
+ this.userIg = ignore().add(this.userPatterns);
364
409
  this.secrets = ignore().add(SECRET_GUARD_GLOBS);
365
410
  }
366
411
  /** Build an engine from a project's `.replicaxignore`, if present. */
@@ -377,6 +422,15 @@ var IgnoreEngine = class _IgnoreEngine {
377
422
  if (!relPosixPath || relPosixPath === ".") return false;
378
423
  return this.ig.ignores(relPosixPath);
379
424
  }
425
+ /**
426
+ * Whether a path is excluded by the user's `.replicaxignore` *only* (ignoring
427
+ * the built-in defaults). Used to apply `.replicaxignore`'s precedence over an
428
+ * explicit `.replicaxinclude` without the defaults vetoing the include.
429
+ */
430
+ isUserIgnored(relPosixPath) {
431
+ if (!relPosixPath || relPosixPath === ".") return false;
432
+ return this.userIg.ignores(relPosixPath);
433
+ }
380
434
  /** Whether a path is a protected secret that must never be captured. */
381
435
  isSecret(relPosixPath) {
382
436
  if (!relPosixPath || relPosixPath === ".") return false;
@@ -439,6 +493,11 @@ async function detectLanguage(root, pkg) {
439
493
  return file === "jsconfig.json" ? "javascript" : "typescript";
440
494
  }
441
495
  }
496
+ if (!pkg) {
497
+ for (const file of ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle"]) {
498
+ if (await fs2.pathExists(path3.join(root, file))) return "java";
499
+ }
500
+ }
442
501
  return "javascript";
443
502
  }
444
503
  function detectFramework(pkg) {
@@ -554,6 +613,464 @@ function renderPackageJson(template, projectName) {
554
613
  return JSON.stringify(ordered, null, 2) + "\n";
555
614
  }
556
615
 
616
+ // src/core/detection/context.ts
617
+ import path4 from "path";
618
+ import fs3 from "fs-extra";
619
+ import fg from "fast-glob";
620
+ var EXACT_CANDIDATES = [
621
+ // package managers / lockfiles
622
+ "package-lock.json",
623
+ "npm-shrinkwrap.json",
624
+ "yarn.lock",
625
+ "pnpm-lock.yaml",
626
+ "bun.lockb",
627
+ "bun.lock",
628
+ "pnpm-workspace.yaml",
629
+ // docker
630
+ "Dockerfile",
631
+ ".dockerignore",
632
+ // ci
633
+ ".gitlab-ci.yml",
634
+ ".circleci/config.yml",
635
+ "Jenkinsfile",
636
+ "azure-pipelines.yml",
637
+ // monorepo / build
638
+ "turbo.json",
639
+ "nx.json",
640
+ "lerna.json",
641
+ // git hooks
642
+ ".husky",
643
+ // editors / ai assistants
644
+ ".vscode",
645
+ ".cursor",
646
+ ".cursorrules",
647
+ ".claude",
648
+ "CLAUDE.md",
649
+ ".windsurf",
650
+ ".windsurfrules",
651
+ ".devcontainer",
652
+ ".devcontainer.json",
653
+ // lint / format (exact flavours)
654
+ ".eslintrc",
655
+ ".prettierrc",
656
+ // language
657
+ "tsconfig.json",
658
+ "jsconfig.json",
659
+ // jvm build
660
+ "pom.xml",
661
+ "mvnw",
662
+ "mvnw.cmd",
663
+ "build.gradle",
664
+ "build.gradle.kts",
665
+ "settings.gradle",
666
+ "settings.gradle.kts",
667
+ "gradlew",
668
+ "gradlew.bat"
669
+ ];
670
+ var GLOB_CANDIDATES = [
671
+ ".github/workflows/*.yml",
672
+ ".github/workflows/*.yaml",
673
+ "Dockerfile.*",
674
+ "docker-compose*.yml",
675
+ "docker-compose*.yaml",
676
+ "compose.yml",
677
+ "compose.yaml",
678
+ "eslint.config.*",
679
+ ".eslintrc.*",
680
+ "prettier.config.*",
681
+ ".prettierrc.*",
682
+ "commitlint.config.*",
683
+ ".commitlintrc",
684
+ ".commitlintrc.*",
685
+ "lint-staged.config.*",
686
+ ".lintstagedrc",
687
+ ".lintstagedrc.*",
688
+ "vitest.config.*",
689
+ "jest.config.*",
690
+ "playwright.config.*",
691
+ "cypress.config.*",
692
+ ".husky/*",
693
+ // JVM build files + Spring resources, globbed with `**/` so a backend nested
694
+ // inside a monorepo (e.g. fullstack JS + Spring) is still detected.
695
+ "**/pom.xml",
696
+ "**/build.gradle",
697
+ "**/build.gradle.kts",
698
+ "**/settings.gradle",
699
+ "**/settings.gradle.kts",
700
+ "**/src/main/resources/application*.yml",
701
+ "**/src/main/resources/application*.yaml",
702
+ "**/src/main/resources/application*.properties"
703
+ ];
704
+ async function gatherContext(root, pkg) {
705
+ const present = /* @__PURE__ */ new Set();
706
+ await Promise.all(
707
+ EXACT_CANDIDATES.map(async (rel) => {
708
+ if (await fs3.pathExists(path4.join(root, rel))) present.add(rel);
709
+ })
710
+ );
711
+ const matches = await fg(GLOB_CANDIDATES, {
712
+ cwd: root,
713
+ dot: true,
714
+ onlyFiles: true,
715
+ unique: true,
716
+ suppressErrors: true,
717
+ followSymbolicLinks: false,
718
+ ignore: SCAN_PRUNE_GLOBS
719
+ });
720
+ for (const m of matches) present.add(toPosix(m));
721
+ const deps = { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
722
+ return {
723
+ root,
724
+ pkg,
725
+ deps,
726
+ present,
727
+ has: (rel) => present.has(rel),
728
+ hasUnder: (prefix) => {
729
+ const p = prefix.replace(/\/+$/, "");
730
+ if (present.has(p)) return true;
731
+ const needle = `${p}/`;
732
+ for (const x of present) if (x.startsWith(needle)) return true;
733
+ return false;
734
+ },
735
+ hasDep: (name) => name in deps
736
+ };
737
+ }
738
+
739
+ // src/core/detection/types.ts
740
+ var Confidence = {
741
+ /** A canonical, unambiguous artifact is present (e.g. a Dockerfile). */
742
+ Confirmed: 1,
743
+ /** Strong signal (e.g. a declared dependency, a pinned field). */
744
+ Strong: 0.9,
745
+ /** Secondary/heuristic evidence (e.g. a related config but not the tool itself). */
746
+ Likely: 0.7,
747
+ /** Weak hint. */
748
+ Possible: 0.5
749
+ };
750
+ function defineDetector(meta, fn) {
751
+ return {
752
+ ...meta,
753
+ detect(ctx) {
754
+ const hit2 = fn(ctx);
755
+ return hit2 ? { ...meta, confidence: hit2.confidence, evidence: hit2.evidence } : null;
756
+ }
757
+ };
758
+ }
759
+ function hit(confidence, ...evidence) {
760
+ return { confidence, evidence };
761
+ }
762
+
763
+ // src/core/detection/detectors/languages.ts
764
+ var LANGUAGE_NAMES = {
765
+ typescript: "TypeScript",
766
+ javascript: "JavaScript",
767
+ java: "Java",
768
+ unknown: null
769
+ };
770
+ var FRAMEWORK_LABELS = {
771
+ next: "Next.js",
772
+ nuxt: "Nuxt",
773
+ remix: "Remix",
774
+ astro: "Astro",
775
+ angular: "Angular",
776
+ sveltekit: "SvelteKit",
777
+ nestjs: "NestJS",
778
+ expo: "Expo",
779
+ "react-native": "React Native",
780
+ vue: "Vue",
781
+ svelte: "Svelte",
782
+ solid: "SolidJS",
783
+ react: "React",
784
+ fastify: "Fastify",
785
+ koa: "Koa",
786
+ express: "Express"
787
+ };
788
+ var FRAMEWORK_SKIP = /* @__PURE__ */ new Set(["unknown", "node"]);
789
+ function metadataDetections(metadata) {
790
+ const out = [];
791
+ const languageName = LANGUAGE_NAMES[metadata.language];
792
+ if (languageName) {
793
+ out.push({
794
+ id: metadata.language,
795
+ name: languageName,
796
+ category: "language",
797
+ confidence: Confidence.Confirmed,
798
+ evidence: ["metadata.language"]
799
+ });
800
+ }
801
+ if (metadata.framework && !FRAMEWORK_SKIP.has(metadata.framework)) {
802
+ out.push({
803
+ id: metadata.framework,
804
+ name: FRAMEWORK_LABELS[metadata.framework] ?? metadata.framework,
805
+ category: "framework",
806
+ confidence: Confidence.Confirmed,
807
+ evidence: ["package.json"]
808
+ });
809
+ }
810
+ return out;
811
+ }
812
+
813
+ // src/core/detection/detectors/packageManagers.ts
814
+ function pmField(ctx) {
815
+ const field = ctx.pkg?.packageManager;
816
+ if (typeof field !== "string") return null;
817
+ return field.split("@")[0]?.trim().toLowerCase() ?? null;
818
+ }
819
+ var packageManagerDetectors = [
820
+ defineDetector({ id: "npm", name: "npm", category: "package-manager" }, (ctx) => {
821
+ if (ctx.has("package-lock.json")) return hit(Confidence.Confirmed, "package-lock.json");
822
+ if (ctx.has("npm-shrinkwrap.json")) return hit(Confidence.Confirmed, "npm-shrinkwrap.json");
823
+ if (pmField(ctx) === "npm") return hit(Confidence.Strong, "package.json#packageManager");
824
+ return null;
825
+ }),
826
+ defineDetector({ id: "pnpm", name: "pnpm", category: "package-manager" }, (ctx) => {
827
+ if (ctx.has("pnpm-lock.yaml")) return hit(Confidence.Confirmed, "pnpm-lock.yaml");
828
+ if (ctx.has("pnpm-workspace.yaml")) return hit(Confidence.Strong, "pnpm-workspace.yaml");
829
+ if (pmField(ctx) === "pnpm") return hit(Confidence.Strong, "package.json#packageManager");
830
+ return null;
831
+ }),
832
+ defineDetector({ id: "yarn", name: "Yarn", category: "package-manager" }, (ctx) => {
833
+ if (ctx.has("yarn.lock")) return hit(Confidence.Confirmed, "yarn.lock");
834
+ if (pmField(ctx) === "yarn") return hit(Confidence.Strong, "package.json#packageManager");
835
+ return null;
836
+ }),
837
+ defineDetector({ id: "bun", name: "Bun", category: "package-manager" }, (ctx) => {
838
+ if (ctx.has("bun.lockb")) return hit(Confidence.Confirmed, "bun.lockb");
839
+ if (ctx.has("bun.lock")) return hit(Confidence.Confirmed, "bun.lock");
840
+ if (pmField(ctx) === "bun") return hit(Confidence.Strong, "package.json#packageManager");
841
+ return null;
842
+ })
843
+ ];
844
+
845
+ // src/core/detection/detectors/tooling.ts
846
+ function match(ctx, re) {
847
+ for (const p of ctx.present) if (re.test(p)) return p;
848
+ return void 0;
849
+ }
850
+ function hasPkgKey(ctx, key) {
851
+ return Boolean(ctx.pkg && typeof ctx.pkg === "object" && key in ctx.pkg);
852
+ }
853
+ var toolingDetectors = [
854
+ // --- Containers ----------------------------------------------------------
855
+ defineDetector({ id: "docker", name: "Docker", category: "container" }, (ctx) => {
856
+ const dockerfile = ctx.has("Dockerfile") ? "Dockerfile" : match(ctx, /^Dockerfile(\.|$)/);
857
+ if (dockerfile) return hit(Confidence.Confirmed, dockerfile);
858
+ if (ctx.has(".dockerignore")) return hit(Confidence.Likely, ".dockerignore");
859
+ return null;
860
+ }),
861
+ defineDetector({ id: "docker-compose", name: "Docker Compose", category: "container" }, (ctx) => {
862
+ const compose = match(ctx, /^(docker-compose|compose).*\.ya?ml$/);
863
+ return compose ? hit(Confidence.Confirmed, compose) : null;
864
+ }),
865
+ // --- CI/CD ---------------------------------------------------------------
866
+ defineDetector({ id: "github-actions", name: "GitHub Actions", category: "ci" }, (ctx) => {
867
+ if (ctx.hasUnder(".github/workflows")) {
868
+ return hit(
869
+ Confidence.Confirmed,
870
+ match(ctx, /^\.github\/workflows\//) ?? ".github/workflows/"
871
+ );
872
+ }
873
+ return null;
874
+ }),
875
+ defineDetector(
876
+ { id: "gitlab-ci", name: "GitLab CI", category: "ci" },
877
+ (ctx) => ctx.has(".gitlab-ci.yml") ? hit(Confidence.Confirmed, ".gitlab-ci.yml") : null
878
+ ),
879
+ defineDetector(
880
+ { id: "circleci", name: "CircleCI", category: "ci" },
881
+ (ctx) => ctx.has(".circleci/config.yml") ? hit(Confidence.Confirmed, ".circleci/config.yml") : null
882
+ ),
883
+ defineDetector(
884
+ { id: "jenkins", name: "Jenkins", category: "ci" },
885
+ (ctx) => ctx.has("Jenkinsfile") ? hit(Confidence.Confirmed, "Jenkinsfile") : null
886
+ ),
887
+ defineDetector(
888
+ { id: "azure-pipelines", name: "Azure Pipelines", category: "ci" },
889
+ (ctx) => ctx.has("azure-pipelines.yml") ? hit(Confidence.Confirmed, "azure-pipelines.yml") : null
890
+ ),
891
+ // --- Monorepo ------------------------------------------------------------
892
+ defineDetector(
893
+ { id: "turborepo", name: "Turborepo", category: "monorepo" },
894
+ (ctx) => ctx.has("turbo.json") ? hit(Confidence.Confirmed, "turbo.json") : null
895
+ ),
896
+ defineDetector(
897
+ { id: "nx", name: "Nx", category: "monorepo" },
898
+ (ctx) => ctx.has("nx.json") ? hit(Confidence.Confirmed, "nx.json") : null
899
+ ),
900
+ // --- Git hooks / commit --------------------------------------------------
901
+ defineDetector({ id: "husky", name: "Husky", category: "git-hooks" }, (ctx) => {
902
+ if (ctx.hasUnder(".husky")) return hit(Confidence.Confirmed, ".husky/");
903
+ if (ctx.hasDep("husky")) return hit(Confidence.Strong, "package.json#husky");
904
+ return null;
905
+ }),
906
+ defineDetector({ id: "lint-staged", name: "lint-staged", category: "commit" }, (ctx) => {
907
+ const cfg = match(ctx, /^(lint-staged\.config\.|\.lintstagedrc)/);
908
+ if (cfg) return hit(Confidence.Confirmed, cfg);
909
+ if (hasPkgKey(ctx, "lint-staged") || hasPkgKey(ctx, "nano-staged")) {
910
+ return hit(Confidence.Confirmed, "package.json#lint-staged");
911
+ }
912
+ if (ctx.hasDep("lint-staged")) return hit(Confidence.Strong, "package.json#lint-staged");
913
+ return null;
914
+ }),
915
+ defineDetector({ id: "commitlint", name: "Commitlint", category: "commit" }, (ctx) => {
916
+ const cfg = match(ctx, /^(commitlint\.config\.|\.commitlintrc)/);
917
+ if (cfg) return hit(Confidence.Confirmed, cfg);
918
+ if (hasPkgKey(ctx, "commitlint")) return hit(Confidence.Confirmed, "package.json#commitlint");
919
+ if (ctx.hasDep("@commitlint/cli")) return hit(Confidence.Strong, "@commitlint/cli");
920
+ return null;
921
+ }),
922
+ // --- Lint / format -------------------------------------------------------
923
+ defineDetector({ id: "eslint", name: "ESLint", category: "lint" }, (ctx) => {
924
+ const cfg = ctx.has(".eslintrc") ? ".eslintrc" : match(ctx, /^(eslint\.config\.|\.eslintrc\.)/);
925
+ if (cfg) return hit(Confidence.Confirmed, cfg);
926
+ if (hasPkgKey(ctx, "eslintConfig"))
927
+ return hit(Confidence.Confirmed, "package.json#eslintConfig");
928
+ if (ctx.hasDep("eslint")) return hit(Confidence.Strong, "eslint");
929
+ return null;
930
+ }),
931
+ defineDetector({ id: "prettier", name: "Prettier", category: "format" }, (ctx) => {
932
+ const cfg = ctx.has(".prettierrc") ? ".prettierrc" : match(ctx, /^(prettier\.config\.|\.prettierrc\.)/);
933
+ if (cfg) return hit(Confidence.Confirmed, cfg);
934
+ if (hasPkgKey(ctx, "prettier")) return hit(Confidence.Confirmed, "package.json#prettier");
935
+ if (ctx.hasDep("prettier")) return hit(Confidence.Strong, "prettier");
936
+ return null;
937
+ }),
938
+ // --- Testing -------------------------------------------------------------
939
+ defineDetector({ id: "vitest", name: "Vitest", category: "test" }, (ctx) => {
940
+ const cfg = match(ctx, /^vitest\.config\./);
941
+ if (cfg) return hit(Confidence.Confirmed, cfg);
942
+ if (ctx.hasDep("vitest")) return hit(Confidence.Strong, "vitest");
943
+ return null;
944
+ }),
945
+ defineDetector({ id: "jest", name: "Jest", category: "test" }, (ctx) => {
946
+ const cfg = match(ctx, /^jest\.config\./);
947
+ if (cfg) return hit(Confidence.Confirmed, cfg);
948
+ if (hasPkgKey(ctx, "jest")) return hit(Confidence.Confirmed, "package.json#jest");
949
+ if (ctx.hasDep("jest")) return hit(Confidence.Strong, "jest");
950
+ return null;
951
+ }),
952
+ defineDetector({ id: "playwright", name: "Playwright", category: "test" }, (ctx) => {
953
+ const cfg = match(ctx, /^playwright\.config\./);
954
+ if (cfg) return hit(Confidence.Confirmed, cfg);
955
+ if (ctx.hasDep("@playwright/test") || ctx.hasDep("playwright")) {
956
+ return hit(Confidence.Strong, "playwright");
957
+ }
958
+ return null;
959
+ }),
960
+ defineDetector({ id: "cypress", name: "Cypress", category: "test" }, (ctx) => {
961
+ const cfg = match(ctx, /^cypress\.config\./);
962
+ if (cfg) return hit(Confidence.Confirmed, cfg);
963
+ if (ctx.hasDep("cypress")) return hit(Confidence.Strong, "cypress");
964
+ return null;
965
+ }),
966
+ // --- Dev containers ------------------------------------------------------
967
+ defineDetector({ id: "devcontainer", name: "Dev Container", category: "devcontainer" }, (ctx) => {
968
+ if (ctx.hasUnder(".devcontainer")) return hit(Confidence.Confirmed, ".devcontainer/");
969
+ if (ctx.has(".devcontainer.json")) return hit(Confidence.Confirmed, ".devcontainer.json");
970
+ return null;
971
+ })
972
+ ];
973
+
974
+ // src/core/detection/detectors/editors.ts
975
+ var editorDetectors = [
976
+ defineDetector(
977
+ { id: "vscode", name: "VS Code", category: "editor" },
978
+ (ctx) => ctx.hasUnder(".vscode") ? hit(Confidence.Confirmed, ".vscode/") : null
979
+ ),
980
+ defineDetector({ id: "cursor", name: "Cursor", category: "ai" }, (ctx) => {
981
+ if (ctx.hasUnder(".cursor")) return hit(Confidence.Confirmed, ".cursor/");
982
+ if (ctx.has(".cursorrules")) return hit(Confidence.Confirmed, ".cursorrules");
983
+ return null;
984
+ }),
985
+ defineDetector({ id: "claude-code", name: "Claude Code", category: "ai" }, (ctx) => {
986
+ if (ctx.hasUnder(".claude")) return hit(Confidence.Confirmed, ".claude/");
987
+ if (ctx.has("CLAUDE.md")) return hit(Confidence.Confirmed, "CLAUDE.md");
988
+ return null;
989
+ }),
990
+ defineDetector({ id: "windsurf", name: "Windsurf", category: "ai" }, (ctx) => {
991
+ if (ctx.hasUnder(".windsurf")) return hit(Confidence.Confirmed, ".windsurf/");
992
+ if (ctx.has(".windsurfrules")) return hit(Confidence.Confirmed, ".windsurfrules");
993
+ return null;
994
+ })
995
+ ];
996
+
997
+ // src/core/detection/detectors/jvm.ts
998
+ function match2(ctx, re) {
999
+ for (const p of ctx.present) if (re.test(p)) return p;
1000
+ return void 0;
1001
+ }
1002
+ var POM = /(^|\/)pom\.xml$/;
1003
+ var GRADLE = /(^|\/)(build|settings)\.gradle(\.kts)?$/;
1004
+ var APP_CONFIG = /(^|\/)src\/main\/resources\/application[^/]*\.(ya?ml|properties)$/;
1005
+ var jvmDetectors = [
1006
+ defineDetector({ id: "maven", name: "Maven", category: "jvm" }, (ctx) => {
1007
+ const pom = match2(ctx, POM);
1008
+ if (pom) return hit(Confidence.Confirmed, pom);
1009
+ if (ctx.has("mvnw") || ctx.has("mvnw.cmd")) return hit(Confidence.Strong, "mvnw");
1010
+ return null;
1011
+ }),
1012
+ defineDetector({ id: "gradle", name: "Gradle", category: "jvm" }, (ctx) => {
1013
+ const build = match2(ctx, GRADLE);
1014
+ if (build) return hit(Confidence.Confirmed, build);
1015
+ if (ctx.has("gradlew") || ctx.has("gradlew.bat")) return hit(Confidence.Strong, "gradlew");
1016
+ return null;
1017
+ }),
1018
+ defineDetector({ id: "spring-boot", name: "Spring Boot", category: "framework" }, (ctx) => {
1019
+ const appConfig = match2(ctx, APP_CONFIG);
1020
+ const hasBuild = Boolean(match2(ctx, POM) || match2(ctx, GRADLE));
1021
+ if (appConfig && hasBuild) return hit(Confidence.Strong, appConfig);
1022
+ if (appConfig) return hit(Confidence.Likely, appConfig);
1023
+ return null;
1024
+ })
1025
+ ];
1026
+
1027
+ // src/core/detection/registry.ts
1028
+ var DETECTORS = [
1029
+ ...packageManagerDetectors,
1030
+ ...toolingDetectors,
1031
+ ...editorDetectors,
1032
+ ...jvmDetectors
1033
+ ];
1034
+ var CATEGORY_ORDER = [
1035
+ "language",
1036
+ "framework",
1037
+ "jvm",
1038
+ "package-manager",
1039
+ "monorepo",
1040
+ "build",
1041
+ "lint",
1042
+ "format",
1043
+ "test",
1044
+ "container",
1045
+ "ci",
1046
+ "git-hooks",
1047
+ "commit",
1048
+ "devcontainer",
1049
+ "editor",
1050
+ "ai"
1051
+ ];
1052
+ function sortDetections(list) {
1053
+ return [...list].sort((a, b) => {
1054
+ const ca = CATEGORY_ORDER.indexOf(a.category);
1055
+ const cb = CATEGORY_ORDER.indexOf(b.category);
1056
+ if (ca !== cb) return ca - cb;
1057
+ return a.name.localeCompare(b.name);
1058
+ });
1059
+ }
1060
+ function runDetectors(ctx) {
1061
+ return DETECTORS.map((d) => d.detect(ctx)).filter((d) => d !== null);
1062
+ }
1063
+ async function detectStack(root, pkg, metadata) {
1064
+ const ctx = await gatherContext(root, pkg);
1065
+ const fromDetectors = runDetectors(ctx);
1066
+ const fromMetadata = metadata ? metadataDetections(metadata) : [];
1067
+ const byId = /* @__PURE__ */ new Map();
1068
+ for (const d of [...fromMetadata, ...fromDetectors]) {
1069
+ if (!byId.has(d.id)) byId.set(d.id, d);
1070
+ }
1071
+ return sortDetections([...byId.values()]);
1072
+ }
1073
+
557
1074
  // src/core/scanner.ts
558
1075
  var FG_BASE_OPTIONS = {
559
1076
  dot: true,
@@ -572,10 +1089,16 @@ function sanitizeNpmrc(content) {
572
1089
  });
573
1090
  return kept.join("\n");
574
1091
  }
1092
+ async function readIncludePatterns(root) {
1093
+ const file = path5.join(root, INCLUDE_FILE);
1094
+ if (!await fs4.pathExists(file)) return [];
1095
+ const content = await fs4.readFile(file, "utf8");
1096
+ return content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#")).map((line) => line.endsWith("/") ? `${line}**` : line);
1097
+ }
575
1098
  async function scanToolingFiles(root, ignore2) {
576
1099
  const categoryOf = /* @__PURE__ */ new Map();
577
1100
  for (const category of CONFIG_CATEGORIES) {
578
- const found = await fg(category.patterns, {
1101
+ const found = await fg2(category.patterns, {
579
1102
  cwd: root,
580
1103
  onlyFiles: true,
581
1104
  unique: true,
@@ -586,43 +1109,67 @@ async function scanToolingFiles(root, ignore2) {
586
1109
  if (!categoryOf.has(norm)) categoryOf.set(norm, category.id);
587
1110
  }
588
1111
  }
1112
+ const includePatterns = await readIncludePatterns(root);
1113
+ const included = /* @__PURE__ */ new Set();
1114
+ if (includePatterns.length > 0) {
1115
+ const found = await fg2(includePatterns, {
1116
+ cwd: root,
1117
+ onlyFiles: true,
1118
+ unique: true,
1119
+ dot: true,
1120
+ followSymbolicLinks: false,
1121
+ suppressErrors: true,
1122
+ ignore: INCLUDE_PRUNE_GLOBS
1123
+ });
1124
+ for (const rel of found) {
1125
+ const norm = toPosix(rel);
1126
+ if (!categoryOf.has(norm)) included.add(norm);
1127
+ }
1128
+ }
1129
+ const candidates = [
1130
+ ...[...categoryOf.entries()].map(
1131
+ ([rel, category]) => ({ rel, source: "catalogue", category })
1132
+ ),
1133
+ ...[...included].map((rel) => ({ rel, source: "include", category: "included" }))
1134
+ ].sort((a, b) => a.rel.localeCompare(b.rel));
589
1135
  const files = [];
590
1136
  const skippedSecrets = [];
591
- for (const rel of [...categoryOf.keys()].sort()) {
1137
+ for (const { rel, source, category } of candidates) {
592
1138
  if (rel === "package.json") continue;
593
1139
  if (ignore2.isSecret(rel)) {
594
1140
  skippedSecrets.push(rel);
595
1141
  logger.detail(`skipped (secret guard): ${rel}`);
596
1142
  continue;
597
1143
  }
598
- if (ignore2.isIgnored(rel)) {
1144
+ const excluded = source === "include" ? ignore2.isUserIgnored(rel) : ignore2.isIgnored(rel);
1145
+ if (excluded) {
599
1146
  logger.detail(`skipped (.replicaxignore): ${rel}`);
600
1147
  continue;
601
1148
  }
602
- const abs = path4.join(root, rel);
1149
+ const abs = path5.join(root, rel);
603
1150
  let stat;
604
1151
  try {
605
- stat = await fs3.stat(abs);
1152
+ stat = await fs4.stat(abs);
606
1153
  } catch {
607
1154
  continue;
608
1155
  }
609
1156
  if (!stat.isFile()) continue;
610
- let content = await fs3.readFile(abs, "utf8");
611
- if (path4.basename(rel) === ".npmrc") content = sanitizeNpmrc(content);
1157
+ let content = await fs4.readFile(abs, "utf8");
1158
+ if (path5.basename(rel) === ".npmrc") content = sanitizeNpmrc(content);
612
1159
  files.push({
613
1160
  path: rel,
614
- category: categoryOf.get(rel) ?? "misc",
1161
+ category,
615
1162
  variant: detectVariant(rel),
616
1163
  encoding: "utf8",
617
1164
  content,
618
1165
  bytes: Buffer.byteLength(content, "utf8")
619
1166
  });
620
- logger.detail(`captured: ${rel}`);
1167
+ logger.detail(`captured${source === "include" ? " (include)" : ""}: ${rel}`);
621
1168
  }
622
1169
  return { files, skippedSecrets };
623
1170
  }
624
1171
  async function scanStructure(root, ignore2) {
625
- const dirs = await fg("**", {
1172
+ const dirs = await fg2("**", {
626
1173
  cwd: root,
627
1174
  onlyDirectories: true,
628
1175
  unique: true,
@@ -630,13 +1177,13 @@ async function scanStructure(root, ignore2) {
630
1177
  });
631
1178
  const directories = dirs.map(toPosix).filter((d) => d.length > 0 && d !== ".").filter((d) => !ignore2.isIgnored(d)).sort();
632
1179
  return {
633
- root: path4.basename(path4.resolve(root)) || "project",
1180
+ root: path5.basename(path5.resolve(root)) || "project",
634
1181
  directories
635
1182
  };
636
1183
  }
637
1184
  async function scanProject(root) {
638
- const resolved = path4.resolve(root);
639
- if (!await fs3.pathExists(resolved)) {
1185
+ const resolved = path5.resolve(root);
1186
+ if (!await fs4.pathExists(resolved)) {
640
1187
  throw new Error(`Directory does not exist: ${resolved}`);
641
1188
  }
642
1189
  const ignore2 = await IgnoreEngine.fromProject(resolved);
@@ -646,11 +1193,13 @@ async function scanProject(root) {
646
1193
  scanStructure(resolved, ignore2),
647
1194
  detectMetadata(resolved, pkg)
648
1195
  ]);
1196
+ const detections = await detectStack(resolved, pkg, metadata);
1197
+ metadata.detections = detections;
649
1198
  const tooling = {
650
1199
  files,
651
1200
  packageJson: buildPackageTemplate(pkg)
652
1201
  };
653
- return { tooling, structure, metadata, pkg, skippedSecrets };
1202
+ return { tooling, structure, metadata, pkg, detections, skippedSecrets };
654
1203
  }
655
1204
 
656
1205
  // src/core/checksum.ts
@@ -688,6 +1237,34 @@ function verifyChecksum(tooling, stored) {
688
1237
  return mismatches;
689
1238
  }
690
1239
 
1240
+ // src/core/manifest.ts
1241
+ function buildManifest(tooling, checksum) {
1242
+ const entries = tooling.files.map((file) => ({
1243
+ path: file.path,
1244
+ category: file.category,
1245
+ variant: file.variant,
1246
+ bytes: file.bytes,
1247
+ sha256: checksum.files[file.path] ?? ""
1248
+ }));
1249
+ if (tooling.packageJson) {
1250
+ entries.push({
1251
+ path: PACKAGE_JSON_KEY,
1252
+ category: "package",
1253
+ variant: "json",
1254
+ // The curated template is a derived artifact, not a captured file on disk,
1255
+ // so byte size is not meaningful — recorded as 0.
1256
+ bytes: 0,
1257
+ sha256: checksum.files[PACKAGE_JSON_KEY] ?? ""
1258
+ });
1259
+ }
1260
+ entries.sort((a, b) => a.path.localeCompare(b.path));
1261
+ return {
1262
+ schemaVersion: REPLICAX_VERSION,
1263
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1264
+ entries
1265
+ };
1266
+ }
1267
+
691
1268
  // src/core/profile-generator.ts
692
1269
  function buildBundle(args) {
693
1270
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -696,36 +1273,56 @@ function buildBundle(args) {
696
1273
  name: args.name,
697
1274
  description: args.description ?? args.existing.description,
698
1275
  replicaxVersion: REPLICAX_VERSION,
699
- updatedAt: now
1276
+ updatedAt: now,
1277
+ // Preserve the original provenance on sync unless explicitly overridden.
1278
+ ...args.source ?? args.existing.source ? { source: args.source ?? args.existing.source } : {}
700
1279
  } : {
701
1280
  name: args.name,
702
1281
  version: "1.0.0",
703
1282
  createdAt: now,
704
1283
  replicaxVersion: REPLICAX_VERSION,
705
- ...args.description ? { description: args.description } : {}
1284
+ ...args.description ? { description: args.description } : {},
1285
+ ...args.source ? { source: args.source } : {}
706
1286
  };
1287
+ const checksum = computeChecksum(args.tooling);
707
1288
  return {
708
1289
  profile,
709
1290
  tooling: args.tooling,
710
1291
  structure: args.structure,
711
1292
  metadata: args.metadata,
712
- checksum: computeChecksum(args.tooling)
1293
+ checksum,
1294
+ manifest: buildManifest(args.tooling, checksum)
713
1295
  };
714
1296
  }
715
1297
 
716
1298
  // src/core/profile-store.ts
717
- import path5 from "path";
718
- import fs4 from "fs-extra";
1299
+ import path6 from "path";
1300
+ import fs5 from "fs-extra";
719
1301
 
720
1302
  // src/schema.ts
721
1303
  import { z } from "zod";
1304
+ var RegistrySchema = z.object({
1305
+ /** Stable identifier within a registry, e.g. "acme/react-enterprise". */
1306
+ id: z.string().optional(),
1307
+ /** Owning namespace/org. */
1308
+ namespace: z.string().optional(),
1309
+ /** Intended visibility once published. */
1310
+ visibility: z.enum(["public", "private"]).optional(),
1311
+ /** Where the profile originated (URL, registry name, …). */
1312
+ source: z.string().optional()
1313
+ });
1314
+ var ProfileSourceSchema = z.enum(["local", "github", "import"]);
722
1315
  var ProfileSchema = z.object({
723
1316
  name: z.string().min(1),
724
1317
  version: z.string().min(1),
725
1318
  createdAt: z.string().min(1),
726
1319
  updatedAt: z.string().optional(),
727
1320
  replicaxVersion: z.string().min(1),
728
- description: z.string().optional()
1321
+ description: z.string().optional(),
1322
+ /** Provenance of the captured setup (added in schema 2.2.0). */
1323
+ source: ProfileSourceSchema.optional(),
1324
+ /** Optional registry metadata (future registry compatibility). */
1325
+ registry: RegistrySchema.optional()
729
1326
  });
730
1327
  var FileVariantSchema = z.enum(["ts", "js", "mjs", "cjs", "json", "yaml", "other"]);
731
1328
  var ToolingFileSchema = z.object({
@@ -759,32 +1356,121 @@ var StructureSchema = z.object({
759
1356
  root: z.string(),
760
1357
  directories: z.array(z.string())
761
1358
  });
1359
+ var DetectionCategorySchema = z.enum([
1360
+ "language",
1361
+ "framework",
1362
+ "package-manager",
1363
+ "monorepo",
1364
+ "container",
1365
+ "ci",
1366
+ "git-hooks",
1367
+ "commit",
1368
+ "lint",
1369
+ "format",
1370
+ "test",
1371
+ "build",
1372
+ "editor",
1373
+ "ai",
1374
+ "devcontainer",
1375
+ "jvm"
1376
+ ]);
1377
+ var DetectionSchema = z.object({
1378
+ /** Stable id, e.g. "docker", "github-actions". */
1379
+ id: z.string().min(1),
1380
+ /** Human-friendly label, e.g. "Docker". */
1381
+ name: z.string().min(1),
1382
+ category: DetectionCategorySchema,
1383
+ /** 0..1 — how sure we are this tool is in use. */
1384
+ confidence: z.number().min(0).max(1),
1385
+ /** Paths/fields that justify the detection (e.g. ["Dockerfile"]). */
1386
+ evidence: z.array(z.string()).default([])
1387
+ });
762
1388
  var MetadataSchema = z.object({
763
1389
  nodeVersion: z.string(),
764
1390
  packageManager: z.enum(["npm", "yarn", "pnpm", "bun", "unknown"]),
765
1391
  framework: z.string(),
766
- language: z.enum(["typescript", "javascript"]),
767
- platform: z.string()
1392
+ language: z.enum(["typescript", "javascript", "java", "unknown"]),
1393
+ platform: z.string(),
1394
+ /** Detected tools/technologies with confidence (added in schema 2.1.0). */
1395
+ detections: z.array(DetectionSchema).optional()
768
1396
  });
769
1397
  var ChecksumSchema = z.object({
770
1398
  algorithm: z.literal("sha256"),
771
1399
  files: z.record(z.string(), z.string())
772
1400
  });
1401
+ var ManifestEntrySchema = z.object({
1402
+ path: z.string().min(1),
1403
+ category: z.string().min(1),
1404
+ variant: FileVariantSchema,
1405
+ bytes: z.number().int().nonnegative(),
1406
+ sha256: z.string()
1407
+ });
1408
+ var ManifestSchema = z.object({
1409
+ schemaVersion: z.string().min(1),
1410
+ generatedAt: z.string().min(1),
1411
+ entries: z.array(ManifestEntrySchema)
1412
+ });
1413
+
1414
+ // src/core/migrations.ts
1415
+ var MIGRATIONS = [
1416
+ {
1417
+ from: "2.0.0",
1418
+ to: "2.1.0",
1419
+ apply(raw) {
1420
+ const metadata = raw.metadata;
1421
+ if (metadata && typeof metadata === "object" && !Array.isArray(metadata.detections)) {
1422
+ metadata.detections = [];
1423
+ }
1424
+ return raw;
1425
+ }
1426
+ },
1427
+ {
1428
+ from: "2.1.0",
1429
+ to: "2.2.0",
1430
+ apply(raw) {
1431
+ return raw;
1432
+ }
1433
+ }
1434
+ ];
1435
+ var KNOWN_VERSIONS = /* @__PURE__ */ new Set([
1436
+ REPLICAX_VERSION,
1437
+ ...MIGRATIONS.flatMap((m) => [m.from, m.to])
1438
+ ]);
1439
+ function migrateRawBundle(raw, detectedVersion) {
1440
+ const steps = [];
1441
+ let current = detectedVersion;
1442
+ let data = raw;
1443
+ for (let guard = 0; guard < MIGRATIONS.length + 1; guard += 1) {
1444
+ if (current === REPLICAX_VERSION) break;
1445
+ const next = MIGRATIONS.find((m) => m.from === current);
1446
+ if (!next) break;
1447
+ data = next.apply(data);
1448
+ steps.push(`${next.from} \u2192 ${next.to}`);
1449
+ current = next.to;
1450
+ }
1451
+ return {
1452
+ raw: data,
1453
+ from: detectedVersion,
1454
+ to: current,
1455
+ migrated: steps.length > 0,
1456
+ steps
1457
+ };
1458
+ }
773
1459
 
774
1460
  // src/core/profile-store.ts
775
1461
  function profileDir(root) {
776
- return path5.join(path5.resolve(root), REPLICAX_DIR);
1462
+ return path6.join(path6.resolve(root), REPLICAX_DIR);
777
1463
  }
778
1464
  async function profileExists(dir) {
779
- return fs4.pathExists(path5.join(dir, PROFILE_FILES.profile));
1465
+ return fs5.pathExists(path6.join(dir, PROFILE_FILES.profile));
780
1466
  }
781
1467
  async function resolveProfileDir(input) {
782
- const resolved = path5.resolve(input);
783
- if (!await fs4.pathExists(resolved)) {
1468
+ const resolved = path6.resolve(input);
1469
+ if (!await fs5.pathExists(resolved)) {
784
1470
  throw new ReplicaxError(`Profile path not found: ${input}`);
785
1471
  }
786
1472
  if (await profileExists(resolved)) return resolved;
787
- const nested = path5.join(resolved, REPLICAX_DIR);
1473
+ const nested = path6.join(resolved, REPLICAX_DIR);
788
1474
  if (await profileExists(nested)) return nested;
789
1475
  throw new ReplicaxError(`No ReplicaX profile found at: ${input}`, [
790
1476
  `Looked for ${PROFILE_FILES.profile} in ${resolved} and ${nested}.`,
@@ -792,26 +1478,29 @@ async function resolveProfileDir(input) {
792
1478
  ]);
793
1479
  }
794
1480
  async function saveBundle(dir, bundle) {
795
- await fs4.ensureDir(dir);
1481
+ await fs5.ensureDir(dir);
1482
+ const manifest = bundle.manifest ?? buildManifest(bundle.tooling, bundle.checksum);
796
1483
  await Promise.all([
797
- fs4.writeJson(path5.join(dir, PROFILE_FILES.profile), bundle.profile, { spaces: 2 }),
798
- fs4.writeJson(path5.join(dir, PROFILE_FILES.tooling), bundle.tooling, { spaces: 2 }),
799
- fs4.writeJson(path5.join(dir, PROFILE_FILES.structure), bundle.structure, { spaces: 2 }),
800
- fs4.writeJson(path5.join(dir, PROFILE_FILES.metadata), bundle.metadata, { spaces: 2 }),
801
- fs4.writeJson(path5.join(dir, PROFILE_FILES.checksum), bundle.checksum, { spaces: 2 })
1484
+ fs5.writeJson(path6.join(dir, PROFILE_FILES.profile), bundle.profile, { spaces: 2 }),
1485
+ fs5.writeJson(path6.join(dir, PROFILE_FILES.tooling), bundle.tooling, { spaces: 2 }),
1486
+ fs5.writeJson(path6.join(dir, PROFILE_FILES.structure), bundle.structure, { spaces: 2 }),
1487
+ fs5.writeJson(path6.join(dir, PROFILE_FILES.metadata), bundle.metadata, { spaces: 2 }),
1488
+ fs5.writeJson(path6.join(dir, PROFILE_FILES.checksum), bundle.checksum, { spaces: 2 }),
1489
+ fs5.writeJson(path6.join(dir, PROFILE_FILES.manifest), manifest, { spaces: 2 })
802
1490
  ]);
803
1491
  }
804
- async function readAndParse(dir, file, schema) {
805
- const full = path5.join(dir, file);
806
- if (!await fs4.pathExists(full)) {
1492
+ async function readRawFile(dir, file) {
1493
+ const full = path6.join(dir, file);
1494
+ if (!await fs5.pathExists(full)) {
807
1495
  throw new ReplicaxError(`Profile is missing ${file}`, [`Expected at ${full}.`]);
808
1496
  }
809
- let raw;
810
1497
  try {
811
- raw = await fs4.readJson(full);
1498
+ return await fs5.readJson(full);
812
1499
  } catch {
813
1500
  throw new ReplicaxError(`Profile file ${file} is not valid JSON`, [`Path: ${full}`]);
814
1501
  }
1502
+ }
1503
+ function parseFile(file, schema, raw) {
815
1504
  const result = schema.safeParse(raw);
816
1505
  if (!result.success) {
817
1506
  const issues = result.error.issues.slice(0, 5).map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`);
@@ -825,17 +1514,35 @@ async function loadBundle(dir) {
825
1514
  "Run `replicax init` to create one."
826
1515
  ]);
827
1516
  }
828
- const [profile, tooling, structure, metadata, checksum] = await Promise.all([
829
- readAndParse(dir, PROFILE_FILES.profile, ProfileSchema),
830
- readAndParse(dir, PROFILE_FILES.tooling, ToolingSchema),
831
- readAndParse(dir, PROFILE_FILES.structure, StructureSchema),
832
- readAndParse(dir, PROFILE_FILES.metadata, MetadataSchema),
833
- readAndParse(dir, PROFILE_FILES.checksum, ChecksumSchema)
834
- ]);
835
- return { profile, tooling, structure, metadata, checksum };
1517
+ const rawFiles = {
1518
+ profile: await readRawFile(dir, PROFILE_FILES.profile),
1519
+ tooling: await readRawFile(dir, PROFILE_FILES.tooling),
1520
+ structure: await readRawFile(dir, PROFILE_FILES.structure),
1521
+ metadata: await readRawFile(dir, PROFILE_FILES.metadata),
1522
+ checksum: await readRawFile(dir, PROFILE_FILES.checksum)
1523
+ };
1524
+ const detectedVersion = typeof rawFiles.profile.replicaxVersion === "string" ? rawFiles.profile.replicaxVersion : "2.0.0";
1525
+ const { raw } = migrateRawBundle(rawFiles, detectedVersion);
1526
+ const profile = parseFile(PROFILE_FILES.profile, ProfileSchema, raw.profile);
1527
+ const tooling = parseFile(PROFILE_FILES.tooling, ToolingSchema, raw.tooling);
1528
+ const structure = parseFile(PROFILE_FILES.structure, StructureSchema, raw.structure);
1529
+ const metadata = parseFile(PROFILE_FILES.metadata, MetadataSchema, raw.metadata);
1530
+ const checksum = parseFile(PROFILE_FILES.checksum, ChecksumSchema, raw.checksum);
1531
+ const manifestPath = path6.join(dir, PROFILE_FILES.manifest);
1532
+ const manifest = await fs5.pathExists(manifestPath) ? parseFile(
1533
+ PROFILE_FILES.manifest,
1534
+ ManifestSchema,
1535
+ await readRawFile(dir, PROFILE_FILES.manifest)
1536
+ ) : buildManifest(tooling, checksum);
1537
+ return { profile, tooling, structure, metadata, checksum, manifest };
836
1538
  }
837
1539
 
838
1540
  // src/commands/report.ts
1541
+ function statusLine(ok, label, note) {
1542
+ const mark = ok ? pc.green("\u2713") : pc.red("\u2717");
1543
+ const text = ok ? label : pc.dim(label);
1544
+ return note ? `${mark} ${text} ${pc.dim(note)}` : `${mark} ${text}`;
1545
+ }
839
1546
  function toolingByCategory(tooling) {
840
1547
  const counts = /* @__PURE__ */ new Map();
841
1548
  for (const file of tooling.files) {
@@ -844,7 +1551,7 @@ function toolingByCategory(tooling) {
844
1551
  if (tooling.packageJson) {
845
1552
  counts.set("package", (counts.get("package") ?? 0) + 1);
846
1553
  }
847
- return [...counts.entries()].map(([id, n]) => [CATEGORY_BY_ID.get(id)?.label ?? id, n]).sort((a, b) => a[0].localeCompare(b[0]));
1554
+ return [...counts.entries()].map(([id, n]) => [categoryLabel(id), n]).sort((a, b) => a[0].localeCompare(b[0]));
848
1555
  }
849
1556
  function printScanSummary(bundle) {
850
1557
  const { metadata, tooling, structure } = bundle;
@@ -854,6 +1561,7 @@ function printScanSummary(bundle) {
854
1561
  logger.hint(`framework ${metadata.framework}`);
855
1562
  logger.hint(`packageManager ${metadata.packageManager}`);
856
1563
  logger.hint(`nodeVersion ${metadata.nodeVersion}`);
1564
+ printDetections(metadata.detections ?? []);
857
1565
  logger.newline();
858
1566
  logger.info(pc.bold(`Tooling (${tooling.files.length + (tooling.packageJson ? 1 : 0)} files)`));
859
1567
  for (const [label, count] of toolingByCategory(tooling)) {
@@ -862,6 +1570,15 @@ function printScanSummary(bundle) {
862
1570
  logger.newline();
863
1571
  logger.info(pc.bold(`Structure (${structure.directories.length} directories)`));
864
1572
  }
1573
+ function printDetections(detections) {
1574
+ if (detections.length === 0) return;
1575
+ logger.newline();
1576
+ logger.info(pc.bold(`Detected (${detections.length})`));
1577
+ for (const d of detections) {
1578
+ const pct = d.confidence < 1 ? pc.dim(` (${Math.round(d.confidence * 100)}%)`) : "";
1579
+ logger.hint(`${pc.green("\u2713")} ${d.name}${pct}`);
1580
+ }
1581
+ }
865
1582
  function reportSkippedSecrets(skipped) {
866
1583
  if (skipped.length === 0) return;
867
1584
  logger.warn(`Excluded ${skipped.length} protected file(s) from the profile:`);
@@ -910,12 +1627,13 @@ async function initCommand(options) {
910
1627
  spinner.succeed(
911
1628
  `Scanned ${scan.tooling.files.length} config file(s) and ${scan.structure.directories.length} director(ies)`
912
1629
  );
913
- const name = options.name ?? path6.basename(path6.resolve(root)) ?? "project";
1630
+ const name = options.name ?? path7.basename(path7.resolve(root)) ?? "project";
914
1631
  const bundle = buildBundle({
915
1632
  name,
916
1633
  tooling: scan.tooling,
917
1634
  structure: scan.structure,
918
- metadata: scan.metadata
1635
+ metadata: scan.metadata,
1636
+ source: "local"
919
1637
  });
920
1638
  reportSkippedSecrets(scan.skippedSecrets);
921
1639
  printScanSummary(bundle);
@@ -935,21 +1653,21 @@ async function initCommand(options) {
935
1653
  logger.hint("Create a project from it with: replicax create <project-name>");
936
1654
  }
937
1655
  async function maybeWriteIgnoreFile(root) {
938
- const file = path6.join(root, IGNORE_FILE);
939
- if (await fs5.pathExists(file)) return;
1656
+ const file = path7.join(root, IGNORE_FILE);
1657
+ if (await fs6.pathExists(file)) return;
940
1658
  const create = process.stdin.isTTY ? await confirm({
941
1659
  message: `Create a starter ${IGNORE_FILE} to control what gets exported?`,
942
1660
  default: true
943
1661
  }) : false;
944
1662
  if (create) {
945
- await fs5.writeFile(file, DEFAULT_IGNORE_FILE_CONTENTS, "utf8");
1663
+ await fs6.writeFile(file, DEFAULT_IGNORE_FILE_CONTENTS, "utf8");
946
1664
  logger.success(`Wrote ${IGNORE_FILE}`);
947
1665
  }
948
1666
  }
949
1667
 
950
1668
  // src/commands/init-skill.ts
951
- import path7 from "path";
952
- import fs6 from "fs-extra";
1669
+ import path8 from "path";
1670
+ import fs7 from "fs-extra";
953
1671
  import ora2 from "ora";
954
1672
 
955
1673
  // src/config/ai-targets.ts
@@ -983,7 +1701,7 @@ function slugify(input, fallback = "project") {
983
1701
  }
984
1702
 
985
1703
  // src/core/skill-generator.ts
986
- var FRAMEWORK_LABELS = {
1704
+ var FRAMEWORK_LABELS2 = {
987
1705
  next: "Next.js",
988
1706
  nuxt: "Nuxt",
989
1707
  remix: "Remix",
@@ -1047,7 +1765,7 @@ function orderedScripts(scripts) {
1047
1765
  function toolingByCategoryLabel(tooling) {
1048
1766
  const groups = /* @__PURE__ */ new Map();
1049
1767
  for (const file of tooling.files) {
1050
- const label = CATEGORY_BY_ID.get(file.category)?.label ?? file.category;
1768
+ const label = categoryLabel(file.category);
1051
1769
  const list = groups.get(label) ?? [];
1052
1770
  list.push(file.path);
1053
1771
  groups.set(label, list);
@@ -1118,7 +1836,7 @@ function buildSkill(args) {
1118
1836
  const { name, metadata, tooling, structure, pkg } = args;
1119
1837
  const slug = slugify(name);
1120
1838
  const pm = metadata.packageManager;
1121
- const framework = FRAMEWORK_LABELS[metadata.framework] ?? metadata.framework;
1839
+ const framework = FRAMEWORK_LABELS2[metadata.framework] ?? metadata.framework;
1122
1840
  const language = metadata.language === "typescript" ? "TypeScript" : "JavaScript";
1123
1841
  const scripts = pkg?.scripts ?? {};
1124
1842
  const description = `${name} project: ${framework}/${language} setup, build/test commands, and tooling conventions. Use this skill when working in or scaffolding this codebase.`;
@@ -1186,6 +1904,9 @@ function buildSkill(args) {
1186
1904
  }
1187
1905
 
1188
1906
  // src/core/ai/cli.ts
1907
+ import { spawn as spawn2 } from "child_process";
1908
+
1909
+ // src/core/process.ts
1189
1910
  import { spawn } from "child_process";
1190
1911
  async function commandExists(bin) {
1191
1912
  const onWindows = process.platform === "win32";
@@ -1202,9 +1923,45 @@ async function commandExists(bin) {
1202
1923
  child.on("close", (code) => resolve(code === 0));
1203
1924
  });
1204
1925
  }
1926
+ async function getCommandOutput(bin, args = [], options = {}) {
1927
+ const { timeoutMs = 5e3, shell = false } = options;
1928
+ return new Promise((resolve) => {
1929
+ let settled = false;
1930
+ const finish = (result) => {
1931
+ if (settled) return;
1932
+ settled = true;
1933
+ resolve(result);
1934
+ };
1935
+ let child;
1936
+ try {
1937
+ child = spawn(bin, args, { shell, windowsHide: true });
1938
+ } catch {
1939
+ finish({ ok: false, stdout: "", stderr: "", code: null });
1940
+ return;
1941
+ }
1942
+ let stdout = "";
1943
+ let stderr = "";
1944
+ const timer = setTimeout(() => {
1945
+ child.kill();
1946
+ finish({ ok: false, stdout, stderr, code: null });
1947
+ }, timeoutMs);
1948
+ child.stdout?.on("data", (d) => stdout += d.toString());
1949
+ child.stderr?.on("data", (d) => stderr += d.toString());
1950
+ child.on("error", () => {
1951
+ clearTimeout(timer);
1952
+ finish({ ok: false, stdout, stderr, code: null });
1953
+ });
1954
+ child.on("close", (code) => {
1955
+ clearTimeout(timer);
1956
+ finish({ ok: code === 0, stdout, stderr, code });
1957
+ });
1958
+ });
1959
+ }
1960
+
1961
+ // src/core/ai/cli.ts
1205
1962
  async function runWithStdin(bin, args, input, timeoutMs = 12e4) {
1206
1963
  return new Promise((resolve, reject) => {
1207
- const child = spawn(bin, args, { shell: true, windowsHide: true });
1964
+ const child = spawn2(bin, args, { shell: true, windowsHide: true });
1208
1965
  let stdout = "";
1209
1966
  let stderr = "";
1210
1967
  const timer = setTimeout(() => {
@@ -1230,6 +1987,14 @@ async function runWithStdin(bin, args, input, timeoutMs = 12e4) {
1230
1987
  }
1231
1988
 
1232
1989
  // src/core/ai/providers.ts
1990
+ var ApiHttpError = class extends Error {
1991
+ constructor(status, message) {
1992
+ super(message);
1993
+ this.status = status;
1994
+ this.name = "ApiHttpError";
1995
+ }
1996
+ status;
1997
+ };
1233
1998
  async function postJson(url, headers, body) {
1234
1999
  const res = await fetch(url, {
1235
2000
  method: "POST",
@@ -1238,10 +2003,33 @@ async function postJson(url, headers, body) {
1238
2003
  });
1239
2004
  if (!res.ok) {
1240
2005
  const text = await res.text().catch(() => "");
1241
- throw new Error(`HTTP ${res.status}: ${text.slice(0, 300) || res.statusText}`);
2006
+ throw new ApiHttpError(res.status, text.slice(0, 300) || res.statusText);
1242
2007
  }
1243
2008
  return res.json();
1244
2009
  }
2010
+ function enrichProviderError(err, def, model) {
2011
+ const status = err instanceof ApiHttpError ? err.status : void 0;
2012
+ const detail = err instanceof Error ? err.message : String(err);
2013
+ const overrideHint = `Override the model with --model <id> or ${def.modelEnvVar}=<id>.`;
2014
+ if (status === 404 || status === 400) {
2015
+ return new ReplicaxError(
2016
+ `${def.label} API rejected model "${model}" (HTTP ${status}). ${overrideHint}`,
2017
+ [`Provider response: ${detail}`]
2018
+ );
2019
+ }
2020
+ if (status === 401 || status === 403) {
2021
+ return new ReplicaxError(`${def.label} API rejected the credentials (HTTP ${status}).`, [
2022
+ `Check ${def.apiEnvVars[0]} holds a valid API key.`,
2023
+ `Provider response: ${detail}`
2024
+ ]);
2025
+ }
2026
+ if (status === 429) {
2027
+ return new ReplicaxError(`${def.label} API rate limit hit (HTTP 429).`, [
2028
+ "Wait a moment and try again."
2029
+ ]);
2030
+ }
2031
+ return new ReplicaxError(`${def.label} API request failed: ${detail}`);
2032
+ }
1245
2033
  async function callAnthropic(prompt, apiKey, model) {
1246
2034
  const data = await postJson(
1247
2035
  "https://api.anthropic.com/v1/messages",
@@ -1318,7 +2106,13 @@ function apiInvoker(def, apiKey, modelOverride) {
1318
2106
  return {
1319
2107
  id: def.id,
1320
2108
  via: `${def.label} API (${model})`,
1321
- run: (prompt) => def.callApi(prompt, apiKey, model)
2109
+ run: async (prompt) => {
2110
+ try {
2111
+ return await def.callApi(prompt, apiKey, model);
2112
+ } catch (err) {
2113
+ throw enrichProviderError(err, def, model);
2114
+ }
2115
+ }
1322
2116
  };
1323
2117
  }
1324
2118
  async function resolveProvider(preference, modelOverride) {
@@ -1348,6 +2142,12 @@ async function resolveProvider(preference, modelOverride) {
1348
2142
  function buildSkillPrompt(args) {
1349
2143
  const scripts = Object.entries(args.scripts).map(([name, cmd]) => ` ${name}: ${cmd}`).join("\n");
1350
2144
  const tooling = args.toolingPaths.map((p) => ` ${p}`).join("\n");
2145
+ const template = args.rootSkill?.trim();
2146
+ const templateRule = template ? "\n- A USER SKILL TEMPLATE (the project's root SKILL.md) is provided below. Use it as the BASE for the entry file: preserve its headings, structure, tone, and any explicit instructions, and refine/fill it in using the PROJECT ANALYSIS. Do not drop content the author put there; do not contradict it." : "";
2147
+ const templateSection = template ? `
2148
+ USER SKILL TEMPLATE (project root SKILL.md \u2014 use this as the base; preserve and refine, keep "name: ${args.slug}" in the frontmatter):
2149
+ ${template}
2150
+ ` : "";
1351
2151
  return `You are an expert developer-tooling assistant. Generate a high-quality "skill" for an AI coding assistant: a document (plus optional supporting files) that teaches the assistant how to work productively in a specific software project.
1352
2152
 
1353
2153
  STRICT RULES:
@@ -1361,11 +2161,11 @@ REQUIREMENTS:
1361
2161
  - Include exactly one entry file whose path is "${args.entryFile}". It MUST start with YAML frontmatter containing "name: ${args.slug}" and a concise single-line "description", then clear markdown covering: tech stack, setup/install, common commands, tooling, project structure, and conventions.
1362
2162
  - You MAY add a few supporting files under "references/" (e.g. "references/commands.md") when genuinely useful. Keep the bundle small and focused.
1363
2163
  - All paths must be relative, use forward slashes, and must NOT contain ".." or be absolute.
1364
- - This skill targets ${args.target.label} and will be installed at ${args.entryPath}.
2164
+ - This skill targets ${args.target.label} and will be installed at ${args.entryPath}.${templateRule}
1365
2165
 
1366
2166
  PROJECT ANALYSIS (ground truth \u2014 refine and expand this, do not contradict it):
1367
2167
  ${args.analysis}
1368
-
2168
+ ${templateSection}
1369
2169
  CAPTURED CONFIG FILES:
1370
2170
  ${tooling || " (none)"}
1371
2171
 
@@ -1431,6 +2231,8 @@ async function initSkillCommand(options) {
1431
2231
  spinner.succeed(
1432
2232
  `Scanned ${scan.tooling.files.length} config file(s) and ${scan.structure.directories.length} director(ies)`
1433
2233
  );
2234
+ const rootSkillPath = path8.join(root, ROOT_SKILL_FILE);
2235
+ const rootSkill = await fs7.pathExists(rootSkillPath) ? await fs7.readFile(rootSkillPath, "utf8") : void 0;
1434
2236
  const name = options.name ?? scan.structure.root;
1435
2237
  const seed = buildSkill({
1436
2238
  name,
@@ -1458,6 +2260,7 @@ async function initSkillCommand(options) {
1458
2260
  const provider = await resolveProvider(options.provider, options.model);
1459
2261
  if (provider) {
1460
2262
  logger.info(`Generating with ${provider.via} (sending project setup only)\u2026`);
2263
+ if (rootSkill?.trim()) logger.info(`Using ${ROOT_SKILL_FILE} as the skill template.`);
1461
2264
  const aiSpinner = ora2({ text: "Authoring skill\u2026", isEnabled: !options.verbose }).start();
1462
2265
  try {
1463
2266
  const prompt = buildSkillPrompt({
@@ -1467,7 +2270,8 @@ async function initSkillCommand(options) {
1467
2270
  target,
1468
2271
  analysis: seed.content,
1469
2272
  toolingPaths: scan.tooling.files.map((f) => f.path),
1470
- scripts: scan.pkg?.scripts ?? {}
2273
+ scripts: scan.pkg?.scripts ?? {},
2274
+ rootSkill
1471
2275
  });
1472
2276
  const raw = await provider.run(prompt);
1473
2277
  const parsed = parseSkillBundle(raw);
@@ -1481,7 +2285,9 @@ async function initSkillCommand(options) {
1481
2285
  }
1482
2286
  } catch (err) {
1483
2287
  aiSpinner.fail("AI generation failed");
1484
- logger.warn(`${err.message}. Falling back to the built-in template.`);
2288
+ logger.warn(`${err.message}`);
2289
+ if (err instanceof ReplicaxError) for (const hint of err.hints) logger.hint(hint);
2290
+ logger.warn("Falling back to the built-in template.");
1485
2291
  }
1486
2292
  } else {
1487
2293
  logger.info("No configured AI provider found \u2014 using the built-in template.");
@@ -1499,11 +2305,11 @@ async function initSkillCommand(options) {
1499
2305
  if (!safe) {
1500
2306
  throw new ReplicaxError(`Refusing to write unsafe skill path: ${f.path}`);
1501
2307
  }
1502
- return { rel: safe, abs: path7.join(root, ...safe.split("/")), content: f.content };
2308
+ return { rel: safe, abs: path8.join(root, ...safe.split("/")), content: f.content };
1503
2309
  });
1504
2310
  const conflicts = [];
1505
2311
  for (const file of planned) {
1506
- if (await fs6.pathExists(file.abs)) conflicts.push(file.rel);
2312
+ if (await fs7.pathExists(file.abs)) conflicts.push(file.rel);
1507
2313
  }
1508
2314
  if (conflicts.length > 0 && !options.force) {
1509
2315
  throw new ReplicaxError(
@@ -1512,27 +2318,193 @@ async function initSkillCommand(options) {
1512
2318
  );
1513
2319
  }
1514
2320
  for (const file of planned) {
1515
- await fs6.ensureDir(path7.dirname(file.abs));
1516
- await fs6.writeFile(file.abs, file.content, "utf8");
2321
+ await fs7.ensureDir(path8.dirname(file.abs));
2322
+ await fs7.writeFile(file.abs, file.content, "utf8");
1517
2323
  logger.detail(`wrote: ${file.rel}`);
1518
2324
  }
1519
2325
  logger.newline();
1520
2326
  logger.success(`Skill "${seed.slug}" written (${planned.length} file(s), via ${via})`);
1521
2327
  logger.hint(
1522
- `Location: ${relPosix(root, path7.join(root, ...(bundleRoot || entryFile).split("/")))}`
2328
+ `Location: ${relPosix(root, path8.join(root, ...(bundleRoot || entryFile).split("/")))}`
1523
2329
  );
1524
2330
  logger.hint(target.note);
1525
2331
  }
1526
2332
 
1527
2333
  // src/commands/extract.ts
1528
- import path9 from "path";
2334
+ import path11 from "path";
1529
2335
  import ora3 from "ora";
1530
2336
 
1531
2337
  // src/core/github.ts
2338
+ import os2 from "os";
2339
+ import path10 from "path";
2340
+ import fs9 from "fs-extra";
2341
+
2342
+ // src/core/archive.ts
2343
+ import { createReadStream } from "fs";
1532
2344
  import os from "os";
1533
- import path8 from "path";
1534
- import fs7 from "fs-extra";
1535
- import { extract as tarExtract } from "tar";
2345
+ import path9 from "path";
2346
+ import fs8 from "fs-extra";
2347
+ import { create as tarCreate, extract as tarExtract, Parser } from "tar";
2348
+ async function exportProfile(profileDirectory, outPath) {
2349
+ const resolvedOut = path9.resolve(outPath);
2350
+ await fs8.ensureDir(path9.dirname(resolvedOut));
2351
+ const parent = path9.dirname(profileDirectory);
2352
+ const base = path9.basename(profileDirectory);
2353
+ await tarCreate(
2354
+ {
2355
+ gzip: true,
2356
+ file: resolvedOut,
2357
+ cwd: parent,
2358
+ // tar strips leading "/" and ".." by default, so extraction stays scoped.
2359
+ portable: true
2360
+ },
2361
+ [base]
2362
+ );
2363
+ }
2364
+ var PROFILE_ARCHIVE_LIMITS = {
2365
+ maxCompressedBytes: 50 * 1024 * 1024,
2366
+ // 50 MB
2367
+ maxTotalBytes: 200 * 1024 * 1024,
2368
+ // 200 MB uncompressed
2369
+ maxEntries: 2e4,
2370
+ maxEntryBytes: 50 * 1024 * 1024,
2371
+ // 50 MB per file
2372
+ allowSymlinks: false
2373
+ };
2374
+ var REPO_ARCHIVE_LIMITS = {
2375
+ maxCompressedBytes: 250 * 1024 * 1024,
2376
+ // 250 MB
2377
+ maxTotalBytes: 1024 * 1024 * 1024,
2378
+ // 1 GB uncompressed
2379
+ maxEntries: 2e5,
2380
+ maxEntryBytes: 200 * 1024 * 1024,
2381
+ // 200 MB per file
2382
+ allowSymlinks: true
2383
+ };
2384
+ function inspectEntry(entry, limits) {
2385
+ const type = String(entry.type);
2386
+ const entryPath = entry.path;
2387
+ if (type === "File" || type === "Directory" || type === "GNUDumpDir") {
2388
+ if (safeJoinable(entryPath) === null) {
2389
+ throw new ReplicaxError(`Refusing to extract unsafe path from archive: "${entryPath}".`, [
2390
+ "The archive may be malicious (path traversal)."
2391
+ ]);
2392
+ }
2393
+ if ((entry.size ?? 0) > limits.maxEntryBytes) {
2394
+ throw new ReplicaxError(`Archive entry "${entryPath}" exceeds the per-file size limit.`);
2395
+ }
2396
+ return "extract";
2397
+ }
2398
+ if (type === "SymbolicLink" || type === "Link") {
2399
+ if (!limits.allowSymlinks) {
2400
+ throw new ReplicaxError(`Refusing to extract link entry from archive: "${entryPath}".`, [
2401
+ "Profile archives never contain symlinks; this one may be malicious."
2402
+ ]);
2403
+ }
2404
+ return "skip";
2405
+ }
2406
+ throw new ReplicaxError(`Refusing to extract "${entryPath}" (unsupported tar entry: ${type}).`);
2407
+ }
2408
+ function validateArchive(resolved, limits) {
2409
+ return new Promise((resolve, reject) => {
2410
+ let entryCount = 0;
2411
+ let totalBytes = 0;
2412
+ let settled = false;
2413
+ const source = createReadStream(resolved);
2414
+ const parser = new Parser({});
2415
+ const fail = (err) => {
2416
+ if (settled) return;
2417
+ settled = true;
2418
+ source.destroy();
2419
+ reject(err);
2420
+ };
2421
+ const finish = () => {
2422
+ if (settled) return;
2423
+ settled = true;
2424
+ resolve();
2425
+ };
2426
+ parser.on("entry", (entry) => {
2427
+ if (settled) {
2428
+ entry.resume();
2429
+ return;
2430
+ }
2431
+ try {
2432
+ if (inspectEntry(entry, limits) === "extract") {
2433
+ entryCount += 1;
2434
+ if (entryCount > limits.maxEntries) {
2435
+ return fail(
2436
+ new ReplicaxError("Archive contains too many entries; refusing to extract.")
2437
+ );
2438
+ }
2439
+ totalBytes += entry.size ?? 0;
2440
+ if (totalBytes > limits.maxTotalBytes) {
2441
+ return fail(
2442
+ new ReplicaxError("Archive is too large when uncompressed; refusing to extract.")
2443
+ );
2444
+ }
2445
+ }
2446
+ } catch (err) {
2447
+ return fail(err);
2448
+ }
2449
+ entry.resume();
2450
+ });
2451
+ parser.on("end", finish);
2452
+ parser.on("error", (err) => fail(err));
2453
+ source.on("error", (err) => fail(err));
2454
+ source.pipe(parser);
2455
+ });
2456
+ }
2457
+ async function safeExtract(archivePath, destDir, limits) {
2458
+ const resolved = path9.resolve(archivePath);
2459
+ const stat = await fs8.stat(resolved).catch(() => null);
2460
+ if (!stat) {
2461
+ throw new ReplicaxError(`Archive not found: ${archivePath}`);
2462
+ }
2463
+ if (stat.size > limits.maxCompressedBytes) {
2464
+ throw new ReplicaxError("Archive file is too large; refusing to extract.");
2465
+ }
2466
+ await validateArchive(resolved, limits);
2467
+ await fs8.ensureDir(destDir);
2468
+ await tarExtract({
2469
+ file: resolved,
2470
+ cwd: destDir,
2471
+ strip: 0,
2472
+ filter: (_p, entry) => {
2473
+ try {
2474
+ return inspectEntry(entry, limits) === "extract";
2475
+ } catch {
2476
+ return false;
2477
+ }
2478
+ }
2479
+ });
2480
+ }
2481
+ async function extractToTemp(archivePath) {
2482
+ if (!await fs8.pathExists(path9.resolve(archivePath))) {
2483
+ throw new ReplicaxError(`Archive not found: ${archivePath}`);
2484
+ }
2485
+ const tmp = await fs8.mkdtemp(path9.join(os.tmpdir(), "replicax-import-"));
2486
+ try {
2487
+ await safeExtract(archivePath, tmp, PROFILE_ARCHIVE_LIMITS);
2488
+ } catch (err) {
2489
+ await fs8.remove(tmp).catch(() => void 0);
2490
+ throw err;
2491
+ }
2492
+ return tmp;
2493
+ }
2494
+ async function findProfileRoot(dir) {
2495
+ const hasProfile = async (d) => fs8.pathExists(path9.join(d, PROFILE_FILES.profile));
2496
+ if (await hasProfile(dir)) return dir;
2497
+ const entries = await fs8.readdir(dir, { withFileTypes: true });
2498
+ for (const entry of entries) {
2499
+ if (entry.isDirectory()) {
2500
+ const candidate = path9.join(dir, entry.name);
2501
+ if (await hasProfile(candidate)) return candidate;
2502
+ }
2503
+ }
2504
+ return null;
2505
+ }
2506
+
2507
+ // src/core/github.ts
1536
2508
  function parseGitHubRef(input) {
1537
2509
  const raw = input.trim();
1538
2510
  if (!raw) {
@@ -1602,9 +2574,9 @@ function httpError(status, slug, hasToken) {
1602
2574
  return new ReplicaxError(`GitHub returned HTTP ${status} for ${slug}.`);
1603
2575
  }
1604
2576
  async function firstSubdir(dir) {
1605
- const entries = await fs7.readdir(dir, { withFileTypes: true });
2577
+ const entries = await fs9.readdir(dir, { withFileTypes: true });
1606
2578
  for (const entry of entries) {
1607
- if (entry.isDirectory()) return path8.join(dir, entry.name);
2579
+ if (entry.isDirectory()) return path10.join(dir, entry.name);
1608
2580
  }
1609
2581
  return null;
1610
2582
  }
@@ -1628,14 +2600,14 @@ async function downloadRepo(ref) {
1628
2600
  if (!res.ok) {
1629
2601
  throw httpError(res.status, slug, Boolean(token));
1630
2602
  }
1631
- const tmpRoot = await fs7.mkdtemp(path8.join(os.tmpdir(), "replicax-extract-"));
1632
- const cleanup = () => fs7.remove(tmpRoot);
2603
+ const tmpRoot = await fs9.mkdtemp(path10.join(os2.tmpdir(), "replicax-extract-"));
2604
+ const cleanup = () => fs9.remove(tmpRoot);
1633
2605
  try {
1634
- const tarPath = path8.join(tmpRoot, "repo.tar.gz");
1635
- await fs7.writeFile(tarPath, Buffer.from(await res.arrayBuffer()));
1636
- const extractDir = path8.join(tmpRoot, "src");
1637
- await fs7.ensureDir(extractDir);
1638
- await tarExtract({ file: tarPath, cwd: extractDir, strip: 0 });
2606
+ const tarPath = path10.join(tmpRoot, "repo.tar.gz");
2607
+ await fs9.writeFile(tarPath, Buffer.from(await res.arrayBuffer()));
2608
+ const extractDir = path10.join(tmpRoot, "src");
2609
+ await fs9.ensureDir(extractDir);
2610
+ await safeExtract(tarPath, extractDir, REPO_ARCHIVE_LIMITS);
1639
2611
  const repoRoot = await firstSubdir(extractDir);
1640
2612
  if (!repoRoot) {
1641
2613
  throw new ReplicaxError(`The downloaded archive for ${slug} was empty.`);
@@ -1677,7 +2649,9 @@ async function extractCommand(repo, options) {
1677
2649
  name,
1678
2650
  tooling: scan.tooling,
1679
2651
  structure: scan.structure,
1680
- metadata: scan.metadata
2652
+ metadata: scan.metadata,
2653
+ // Captured from a remote repo we don't control — untrusted for auto-install.
2654
+ source: "github"
1681
2655
  });
1682
2656
  reportSkippedSecrets(scan.skippedSecrets);
1683
2657
  printScanSummary(bundle);
@@ -1687,7 +2661,7 @@ async function extractCommand(repo, options) {
1687
2661
  logger.info("Dry run \u2014 no files were written.");
1688
2662
  return;
1689
2663
  }
1690
- const outRoot = options.out ? path9.resolve(options.out) : process.cwd();
2664
+ const outRoot = options.out ? path11.resolve(options.out) : process.cwd();
1691
2665
  const dir = profileDir(outRoot);
1692
2666
  if (await profileExists(dir)) {
1693
2667
  logger.warn(
@@ -1704,8 +2678,7 @@ async function extractCommand(repo, options) {
1704
2678
  }
1705
2679
 
1706
2680
  // src/commands/create.ts
1707
- import path11 from "path";
1708
- import fs9 from "fs-extra";
2681
+ import path13 from "path";
1709
2682
 
1710
2683
  // src/core/conflict-resolver.ts
1711
2684
  import { select } from "@inquirer/prompts";
@@ -1747,8 +2720,8 @@ var ConflictResolver = class {
1747
2720
  };
1748
2721
 
1749
2722
  // src/core/project-generator.ts
1750
- import path10 from "path";
1751
- import fs8 from "fs-extra";
2723
+ import path12 from "path";
2724
+ import fs10 from "fs-extra";
1752
2725
  async function generateProject(options) {
1753
2726
  const { bundle, targetDir, projectName, dryRun, conflict } = options;
1754
2727
  const result = {
@@ -1758,16 +2731,16 @@ async function generateProject(options) {
1758
2731
  filesSkipped: 0,
1759
2732
  unsafeSkipped: []
1760
2733
  };
1761
- if (!dryRun) await fs8.ensureDir(targetDir);
2734
+ if (!dryRun) await fs10.ensureDir(targetDir);
1762
2735
  for (const dir of bundle.structure.directories) {
1763
2736
  const safe = safeJoinable(dir);
1764
2737
  if (!safe) {
1765
2738
  result.unsafeSkipped.push(dir);
1766
2739
  continue;
1767
2740
  }
1768
- const full = path10.join(targetDir, safe);
1769
- const existed = await fs8.pathExists(full);
1770
- if (!dryRun) await fs8.ensureDir(full);
2741
+ const full = path12.join(targetDir, safe);
2742
+ const existed = await fs10.pathExists(full);
2743
+ if (!dryRun) await fs10.ensureDir(full);
1771
2744
  if (!existed) result.dirsCreated += 1;
1772
2745
  result.entries.push({ kind: "dir", path: safe, action: existed ? "skip" : "create" });
1773
2746
  }
@@ -1791,8 +2764,8 @@ async function writeFile(relPath, content, options, result) {
1791
2764
  logger.warn(`Refusing to write unsafe path from profile: ${relPath}`);
1792
2765
  return;
1793
2766
  }
1794
- const full = path10.join(options.targetDir, safe);
1795
- const exists = await fs8.pathExists(full);
2767
+ const full = path12.join(options.targetDir, safe);
2768
+ const exists = await fs10.pathExists(full);
1796
2769
  let action2 = exists ? "overwrite" : "create";
1797
2770
  if (exists) {
1798
2771
  const decision = await options.conflict.resolve(safe);
@@ -1805,8 +2778,8 @@ async function writeFile(relPath, content, options, result) {
1805
2778
  action2 = "overwrite";
1806
2779
  }
1807
2780
  if (!options.dryRun) {
1808
- await fs8.ensureDir(path10.dirname(full));
1809
- await fs8.writeFile(full, content, "utf8");
2781
+ await fs10.ensureDir(path12.dirname(full));
2782
+ await fs10.writeFile(full, content, "utf8");
1810
2783
  }
1811
2784
  result.filesWritten += 1;
1812
2785
  result.entries.push({ kind: "file", path: safe, action: action2 });
@@ -1814,7 +2787,7 @@ async function writeFile(relPath, content, options, result) {
1814
2787
  }
1815
2788
 
1816
2789
  // src/core/installer.ts
1817
- import { spawn as spawn2 } from "child_process";
2790
+ import { spawn as spawn3 } from "child_process";
1818
2791
  var COMMANDS = {
1819
2792
  npm: ["npm", "install"],
1820
2793
  pnpm: ["pnpm", "install"],
@@ -1825,7 +2798,7 @@ function installDependencies(cwd, manager) {
1825
2798
  if (manager === "unknown") return Promise.resolve(false);
1826
2799
  const [command, ...args] = COMMANDS[manager];
1827
2800
  return new Promise((resolve) => {
1828
- const child = spawn2(command, args, {
2801
+ const child = spawn3(command, args, {
1829
2802
  cwd,
1830
2803
  stdio: "inherit",
1831
2804
  // npm/pnpm/yarn are .cmd shims on Windows; a shell resolves them.
@@ -1855,9 +2828,9 @@ async function createCommand(projectName, options) {
1855
2828
  logger.warn(`Profile integrity check found ${mismatches.length} issue(s); continuing anyway.`);
1856
2829
  logger.hint("Run `replicax validate` for details.");
1857
2830
  }
1858
- const targetDir = path11.resolve(process.cwd(), projectName);
1859
- const leafName = path11.basename(targetDir);
1860
- if (path11.resolve(process.cwd()) === targetDir) {
2831
+ const targetDir = path13.resolve(process.cwd(), projectName);
2832
+ const leafName = path13.basename(targetDir);
2833
+ if (path13.resolve(process.cwd()) === targetDir) {
1861
2834
  throw new ReplicaxError("Refusing to scaffold into the current directory.", [
1862
2835
  "Pass a new project name, e.g. `replicax create my-app`."
1863
2836
  ]);
@@ -1887,63 +2860,95 @@ async function createCommand(projectName, options) {
1887
2860
  return;
1888
2861
  }
1889
2862
  logger.hint(`Location: ${relPosix(process.cwd(), targetDir)}/`);
1890
- await maybeInstall(
1891
- bundle.metadata.packageManager,
1892
- targetDir,
1893
- options,
1894
- Boolean(bundle.tooling.packageJson)
1895
- );
2863
+ await maybeInstall(bundle, targetDir, options);
1896
2864
  logger.newline();
1897
2865
  logger.success(`Project ${pc.bold(leafName)} is ready.`);
1898
2866
  }
1899
- async function maybeInstall(manager, targetDir, options, hasPackageJson) {
1900
- if (options.skipInstall) {
1901
- logger.hint("Skipped dependency install (--skip-install).");
1902
- return;
1903
- }
1904
- if (!hasPackageJson) return;
1905
- if (manager === "unknown") {
1906
- logger.hint("No package manager detected; run your install command manually.");
1907
- return;
1908
- }
1909
- const pkgPath = path11.join(targetDir, "package.json");
1910
- const pkg = await fs9.readJson(pkgPath).catch(() => null);
1911
- if (!pkg?.devDependencies || Object.keys(pkg.devDependencies).length === 0) {
1912
- logger.hint("No dependencies to install.");
1913
- return;
2867
+ function decideInstall(bundle, options) {
2868
+ if (options.skipInstall) return { kind: "skip-flag" };
2869
+ const devDeps = bundle.tooling.packageJson?.devDependencies ?? {};
2870
+ if (Object.keys(devDeps).length === 0) return { kind: "no-deps" };
2871
+ const manager = bundle.metadata.packageManager;
2872
+ if (manager === "unknown") return { kind: "no-manager" };
2873
+ const source = bundle.profile.source ?? "local";
2874
+ const trusted = source === "local";
2875
+ if (!trusted && !options.install) return { kind: "blocked", source, manager, devDeps };
2876
+ return { kind: "install", trusted, source, manager, devDeps };
2877
+ }
2878
+ function printDependencySummary(devDeps) {
2879
+ const names = Object.keys(devDeps).sort();
2880
+ for (const name of names.slice(0, 10)) logger.hint(` ${name} ${devDeps[name]}`);
2881
+ if (names.length > 10) logger.hint(` \u2026and ${names.length - 10} more`);
2882
+ }
2883
+ async function maybeInstall(bundle, targetDir, options) {
2884
+ const decision = decideInstall(bundle, options);
2885
+ switch (decision.kind) {
2886
+ case "skip-flag":
2887
+ logger.hint("Skipped dependency install (--skip-install).");
2888
+ return;
2889
+ case "no-deps":
2890
+ if (bundle.tooling.packageJson) logger.hint("No dependencies to install.");
2891
+ return;
2892
+ case "no-manager":
2893
+ logger.hint("No package manager detected; run your install command manually.");
2894
+ return;
2895
+ case "blocked":
2896
+ logger.newline();
2897
+ logger.warn(
2898
+ `Profile source is "${decision.source}" \u2014 skipping dependency install for safety.`
2899
+ );
2900
+ logger.hint(
2901
+ `Installing runs package lifecycle scripts. ${Object.keys(decision.devDeps).length} devDependencies would be added with ${decision.manager}:`
2902
+ );
2903
+ printDependencySummary(decision.devDeps);
2904
+ logger.hint(
2905
+ `Review them, then run \`${decision.manager} install\`, or re-run create with --install.`
2906
+ );
2907
+ return;
2908
+ case "install": {
2909
+ logger.newline();
2910
+ if (!decision.trusted) {
2911
+ logger.warn(
2912
+ `Installing for an untrusted ("${decision.source}") profile \u2014 package lifecycle scripts can execute code.`
2913
+ );
2914
+ }
2915
+ logger.info(
2916
+ `Installing ${Object.keys(decision.devDeps).length} devDependencies with ${decision.manager}\u2026`
2917
+ );
2918
+ printDependencySummary(decision.devDeps);
2919
+ const ok = await installDependencies(targetDir, decision.manager);
2920
+ if (ok) logger.success("Dependencies installed.");
2921
+ else logger.warn("Dependency install did not complete; run it manually.");
2922
+ return;
2923
+ }
1914
2924
  }
1915
- logger.newline();
1916
- logger.info(`Installing dependencies with ${manager}\u2026`);
1917
- const ok = await installDependencies(targetDir, manager);
1918
- if (ok) logger.success("Dependencies installed.");
1919
- else logger.warn("Dependency install did not complete; run it manually.");
1920
2925
  }
1921
2926
 
1922
2927
  // src/commands/sync.ts
1923
2928
  import ora4 from "ora";
1924
2929
 
1925
2930
  // src/core/diff.ts
1926
- function diffChecksums(prev, next) {
2931
+ function diffStringMaps(prev, next, options = {}) {
2932
+ const ignore2 = options.ignoreKeys;
1927
2933
  const added = [];
1928
2934
  const removed = [];
1929
2935
  const changed = [];
1930
- let packageJsonChanged = false;
1931
- const keys = /* @__PURE__ */ new Set([...Object.keys(prev.files), ...Object.keys(next.files)]);
2936
+ const keys = /* @__PURE__ */ new Set([...Object.keys(prev), ...Object.keys(next)]);
1932
2937
  for (const key of keys) {
1933
- const before = prev.files[key];
1934
- const after = next.files[key];
1935
- if (key === PACKAGE_JSON_KEY) {
1936
- if (before !== after) packageJsonChanged = true;
1937
- continue;
1938
- }
2938
+ if (ignore2?.has(key)) continue;
2939
+ const before = prev[key];
2940
+ const after = next[key];
1939
2941
  if (before === void 0) added.push(key);
1940
2942
  else if (after === void 0) removed.push(key);
1941
2943
  else if (before !== after) changed.push(key);
1942
2944
  }
1943
- return {
1944
- files: { added: added.sort(), removed: removed.sort(), changed: changed.sort() },
1945
- packageJsonChanged
1946
- };
2945
+ return { added: added.sort(), removed: removed.sort(), changed: changed.sort() };
2946
+ }
2947
+ var PACKAGE_JSON_KEYS = /* @__PURE__ */ new Set([PACKAGE_JSON_KEY]);
2948
+ function diffChecksums(prev, next) {
2949
+ const files = diffStringMaps(prev.files, next.files, { ignoreKeys: PACKAGE_JSON_KEYS });
2950
+ const packageJsonChanged = prev.files[PACKAGE_JSON_KEY] !== next.files[PACKAGE_JSON_KEY];
2951
+ return { files, packageJsonChanged };
1947
2952
  }
1948
2953
  function diffStructure(prev, next) {
1949
2954
  const before = new Set(prev.directories);
@@ -1999,6 +3004,8 @@ async function syncCommand(options) {
1999
3004
  tooling: scan.tooling,
2000
3005
  structure: scan.structure,
2001
3006
  metadata: scan.metadata,
3007
+ // A sync re-captures the local project, so the result is locally trusted.
3008
+ source: "local",
2002
3009
  existing: existing.profile
2003
3010
  });
2004
3011
  const diff = diffBundles(existing, next);
@@ -2053,7 +3060,7 @@ function formatBytes(bytes) {
2053
3060
  }
2054
3061
 
2055
3062
  // src/commands/inspect.ts
2056
- var SECTIONS = ["profile", "tooling", "structure", "metadata"];
3063
+ var SECTIONS = ["profile", "tooling", "structure", "metadata", "detections"];
2057
3064
  async function inspectCommand(options) {
2058
3065
  const dir = options.profile ? await resolveProfileDir(options.profile) : profileDir(process.cwd());
2059
3066
  if (!await profileExists(dir)) {
@@ -2067,15 +3074,38 @@ async function inspectCommand(options) {
2067
3074
  const bundle = await loadBundle(dir);
2068
3075
  const section = options.section;
2069
3076
  if (options.json) {
2070
- const payload = section ? { [section]: bundle[section] } : bundle;
2071
- logger.out(JSON.stringify(payload, null, 2));
3077
+ logger.out(JSON.stringify(jsonPayload(bundle, section), null, 2));
2072
3078
  return;
2073
3079
  }
2074
3080
  if (!section || section === "profile") printProfile(bundle);
2075
3081
  if (!section || section === "metadata") printMetadata(bundle);
3082
+ if (!section || section === "detections") printDetectionsSection(bundle);
2076
3083
  if (!section || section === "tooling") printTooling(bundle);
2077
3084
  if (!section || section === "structure") printStructure(bundle);
2078
3085
  }
3086
+ function jsonPayload(bundle, section) {
3087
+ if (!section) return bundle;
3088
+ if (section === "detections") return { detections: bundle.metadata.detections ?? [] };
3089
+ return { [section]: bundle[section] };
3090
+ }
3091
+ function printDetectionsSection(bundle) {
3092
+ const detections = bundle.metadata.detections ?? [];
3093
+ logger.out(pc.bold(`Detections (${detections.length})`));
3094
+ if (detections.length === 0) {
3095
+ logger.out(" (none)");
3096
+ logger.out("");
3097
+ return;
3098
+ }
3099
+ const table = new Table({
3100
+ head: ["Category", "Tool", "Confidence", "Evidence"],
3101
+ style: { head: ["cyan"], border: ["dim"] }
3102
+ });
3103
+ for (const d of detections) {
3104
+ table.push([d.category, d.name, `${Math.round(d.confidence * 100)}%`, d.evidence.join(", ")]);
3105
+ }
3106
+ logger.out(table.toString());
3107
+ logger.out("");
3108
+ }
2079
3109
  function printProfile(bundle) {
2080
3110
  const p = bundle.profile;
2081
3111
  logger.out(pc.bold("Profile"));
@@ -2109,12 +3139,7 @@ function printTooling(bundle) {
2109
3139
  table.push(["Package Management & Monorepos", "package.json", "json", "template"]);
2110
3140
  }
2111
3141
  for (const file of [...tooling.files].sort((a, b) => a.path.localeCompare(b.path))) {
2112
- table.push([
2113
- CATEGORY_BY_ID.get(file.category)?.label ?? file.category,
2114
- file.path,
2115
- file.variant,
2116
- formatBytes(file.bytes)
2117
- ]);
3142
+ table.push([categoryLabel(file.category), file.path, file.variant, formatBytes(file.bytes)]);
2118
3143
  }
2119
3144
  logger.out(table.toString());
2120
3145
  logger.out("");
@@ -2162,61 +3187,16 @@ async function validateCommand(options) {
2162
3187
  }
2163
3188
 
2164
3189
  // src/commands/export.ts
2165
- import path13 from "path";
3190
+ import path14 from "path";
2166
3191
  import fs11 from "fs-extra";
2167
3192
  import ora5 from "ora";
2168
-
2169
- // src/core/archive.ts
2170
- import os2 from "os";
2171
- import path12 from "path";
2172
- import fs10 from "fs-extra";
2173
- import { create as tarCreate, extract as tarExtract2 } from "tar";
2174
- async function exportProfile(profileDirectory, outPath) {
2175
- const resolvedOut = path12.resolve(outPath);
2176
- await fs10.ensureDir(path12.dirname(resolvedOut));
2177
- const parent = path12.dirname(profileDirectory);
2178
- const base = path12.basename(profileDirectory);
2179
- await tarCreate(
2180
- {
2181
- gzip: true,
2182
- file: resolvedOut,
2183
- cwd: parent,
2184
- // tar strips leading "/" and ".." by default, so extraction stays scoped.
2185
- portable: true
2186
- },
2187
- [base]
2188
- );
2189
- }
2190
- async function extractToTemp(archivePath) {
2191
- const resolved = path12.resolve(archivePath);
2192
- if (!await fs10.pathExists(resolved)) {
2193
- throw new Error(`Archive not found: ${archivePath}`);
2194
- }
2195
- const tmp = await fs10.mkdtemp(path12.join(os2.tmpdir(), "replicax-import-"));
2196
- await tarExtract2({ file: resolved, cwd: tmp, strip: 0 });
2197
- return tmp;
2198
- }
2199
- async function findProfileRoot(dir) {
2200
- const hasProfile = async (d) => fs10.pathExists(path12.join(d, PROFILE_FILES.profile));
2201
- if (await hasProfile(dir)) return dir;
2202
- const entries = await fs10.readdir(dir, { withFileTypes: true });
2203
- for (const entry of entries) {
2204
- if (entry.isDirectory()) {
2205
- const candidate = path12.join(dir, entry.name);
2206
- if (await hasProfile(candidate)) return candidate;
2207
- }
2208
- }
2209
- return null;
2210
- }
2211
-
2212
- // src/commands/export.ts
2213
3193
  async function exportCommand(options) {
2214
3194
  const dir = options.profile ? await resolveProfileDir(options.profile) : profileDir(process.cwd());
2215
3195
  if (!await profileExists(dir)) {
2216
3196
  throw new ReplicaxError("No ReplicaX profile found to export.", ["Run `replicax init` first."]);
2217
3197
  }
2218
3198
  const bundle = await loadBundle(dir);
2219
- const outPath = path13.resolve(
3199
+ const outPath = path14.resolve(
2220
3200
  options.out ?? `${slugify(bundle.profile.name, "profile")}.replicax.tar.gz`
2221
3201
  );
2222
3202
  const spinner = ora5({ text: "Packaging profile\u2026" }).start();
@@ -2224,7 +3204,7 @@ async function exportCommand(options) {
2224
3204
  spinner.stop();
2225
3205
  const { size } = await fs11.stat(outPath);
2226
3206
  logger.success(
2227
- `Exported "${bundle.profile.name}" \u2192 ${path13.relative(process.cwd(), outPath)} (${formatBytes(size)})`
3207
+ `Exported "${bundle.profile.name}" \u2192 ${path14.relative(process.cwd(), outPath)} (${formatBytes(size)})`
2228
3208
  );
2229
3209
  logger.hint("Share it, then `replicax import <file>` elsewhere.");
2230
3210
  }
@@ -2246,6 +3226,7 @@ async function importCommand(archivePath, options) {
2246
3226
  throw new ReplicaxError("The archive does not contain a ReplicaX profile.");
2247
3227
  }
2248
3228
  const bundle = await loadBundle(source);
3229
+ bundle.profile.source = "import";
2249
3230
  spinner.succeed(`Validated profile "${bundle.profile.name}"`);
2250
3231
  const dest = profileDir(process.cwd());
2251
3232
  if (await profileExists(dest)) {
@@ -2271,6 +3252,407 @@ async function importCommand(archivePath, options) {
2271
3252
  }
2272
3253
  }
2273
3254
 
3255
+ // src/config/environment-tools.ts
3256
+ var ENVIRONMENT_TOOLS = [
3257
+ { id: "node", name: "Node.js", bin: "node", versionArgs: ["--version"], kind: "runtime" },
3258
+ { id: "git", name: "Git", bin: "git", versionArgs: ["--version"], kind: "vcs" },
3259
+ { id: "npm", name: "npm", bin: "npm", versionArgs: ["--version"], kind: "package-manager" },
3260
+ { id: "pnpm", name: "pnpm", bin: "pnpm", versionArgs: ["--version"], kind: "package-manager" },
3261
+ { id: "yarn", name: "Yarn", bin: "yarn", versionArgs: ["--version"], kind: "package-manager" },
3262
+ { id: "bun", name: "Bun", bin: "bun", versionArgs: ["--version"], kind: "package-manager" },
3263
+ { id: "docker", name: "Docker", bin: "docker", versionArgs: ["--version"], kind: "container" },
3264
+ {
3265
+ id: "vscode",
3266
+ name: "VS Code",
3267
+ bin: "code",
3268
+ versionArgs: ["--version"],
3269
+ kind: "editor",
3270
+ // `code --version` prints version on the first line, then commit + arch.
3271
+ parseVersion: (raw) => raw.split(/\r?\n/)[0]?.trim() || void 0
3272
+ },
3273
+ { id: "cursor", name: "Cursor", bin: "cursor", versionArgs: ["--version"], kind: "editor" },
3274
+ {
3275
+ id: "claude-code",
3276
+ name: "Claude Code",
3277
+ bin: "claude",
3278
+ versionArgs: ["--version"],
3279
+ kind: "editor"
3280
+ },
3281
+ { id: "windsurf", name: "Windsurf", bin: "windsurf", versionArgs: ["--version"], kind: "editor" }
3282
+ ];
3283
+
3284
+ // src/core/environment.ts
3285
+ function parseVersionDefault(raw) {
3286
+ const trimmed = raw.trim();
3287
+ const semver = trimmed.match(/\d+\.\d+\.\d+(?:[-+][\w.]+)?/);
3288
+ if (semver) return semver[0];
3289
+ const loose = trimmed.match(/\d+\.\d+/);
3290
+ return loose ? loose[0] : void 0;
3291
+ }
3292
+ var defaultProbe = async (tool) => {
3293
+ const out = await getCommandOutput(tool.bin, tool.versionArgs, { shell: true });
3294
+ if (out.ok) {
3295
+ const raw = out.stdout.trim() || out.stderr.trim();
3296
+ const parse = tool.parseVersion ?? parseVersionDefault;
3297
+ return { found: true, version: parse(raw) };
3298
+ }
3299
+ return { found: await commandExists(tool.bin) };
3300
+ };
3301
+ async function runEnvironmentChecks(tools = ENVIRONMENT_TOOLS, probe = defaultProbe) {
3302
+ return Promise.all(
3303
+ tools.map(async (tool) => {
3304
+ const result = await probe(tool);
3305
+ return {
3306
+ id: tool.id,
3307
+ name: tool.name,
3308
+ kind: tool.kind,
3309
+ found: result.found,
3310
+ version: result.version
3311
+ };
3312
+ })
3313
+ );
3314
+ }
3315
+
3316
+ // src/commands/doctor.ts
3317
+ async function doctorCommand(options) {
3318
+ const checks = await runEnvironmentChecks();
3319
+ if (options.json) {
3320
+ logger.out(JSON.stringify({ checks }, null, 2));
3321
+ return;
3322
+ }
3323
+ logger.out(pc.bold("Developer environment"));
3324
+ logger.out("");
3325
+ for (const check of checks) {
3326
+ const note = check.found ? check.version : "not found";
3327
+ logger.out(statusLine(check.found, check.name, note));
3328
+ }
3329
+ const found = checks.filter((c) => c.found).length;
3330
+ logger.out("");
3331
+ logger.out(pc.dim(`${found}/${checks.length} tools found`));
3332
+ }
3333
+
3334
+ // src/commands/compare.ts
3335
+ import path15 from "path";
3336
+ import fs13 from "fs-extra";
3337
+
3338
+ // src/core/compare.ts
3339
+ function detectionsOf(bundle) {
3340
+ return bundle.metadata.detections ?? [];
3341
+ }
3342
+ var toolingComparator = {
3343
+ id: "tooling",
3344
+ title: "Tooling",
3345
+ compare(a, b) {
3346
+ const aById = new Map(detectionsOf(a).map((d) => [d.id, d]));
3347
+ const bById = new Map(detectionsOf(b).map((d) => [d.id, d]));
3348
+ const added = [];
3349
+ const removed = [];
3350
+ const changed = [];
3351
+ for (const [id, d] of bById) if (!aById.has(id)) added.push(d.name);
3352
+ for (const [id, d] of aById) if (!bById.has(id)) removed.push(d.name);
3353
+ for (const [id, d] of aById) {
3354
+ const other = bById.get(id);
3355
+ if (other && other.confidence !== d.confidence) changed.push(d.name);
3356
+ }
3357
+ return sortSection({ id: this.id, title: this.title, added, removed, changed });
3358
+ }
3359
+ };
3360
+ var PACKAGE_JSON_KEYS2 = /* @__PURE__ */ new Set([PACKAGE_JSON_KEY]);
3361
+ var configFilesComparator = {
3362
+ id: "config-files",
3363
+ title: "Configuration files",
3364
+ compare(a, b) {
3365
+ const diff = diffStringMaps(a.checksum.files, b.checksum.files, {
3366
+ ignoreKeys: PACKAGE_JSON_KEYS2
3367
+ });
3368
+ return { id: this.id, title: this.title, ...diff };
3369
+ }
3370
+ };
3371
+ var packageJsonComparator = {
3372
+ id: "package-json",
3373
+ title: "package.json",
3374
+ compare(a, b) {
3375
+ const flatten = (bundle) => {
3376
+ const pkg = bundle.tooling.packageJson;
3377
+ const out = {};
3378
+ for (const [name, cmd] of Object.entries(pkg?.scripts ?? {})) out[`script:${name}`] = cmd;
3379
+ for (const [name, ver] of Object.entries(pkg?.devDependencies ?? {})) {
3380
+ out[`devDependency:${name}`] = ver;
3381
+ }
3382
+ return out;
3383
+ };
3384
+ const diff = diffStringMaps(flatten(a), flatten(b));
3385
+ return { id: this.id, title: this.title, ...diff };
3386
+ }
3387
+ };
3388
+ var structureComparator = {
3389
+ id: "structure",
3390
+ title: "Structure",
3391
+ compare(a, b) {
3392
+ const before = new Set(a.structure.directories);
3393
+ const after = new Set(b.structure.directories);
3394
+ const added = b.structure.directories.filter((d) => !before.has(d));
3395
+ const removed = a.structure.directories.filter((d) => !after.has(d));
3396
+ return sortSection({ id: this.id, title: this.title, added, removed, changed: [] });
3397
+ }
3398
+ };
3399
+ var metadataComparator = {
3400
+ id: "metadata",
3401
+ title: "Metadata",
3402
+ compare(a, b) {
3403
+ const fields = [
3404
+ "language",
3405
+ "framework",
3406
+ "packageManager",
3407
+ "nodeVersion"
3408
+ ];
3409
+ const changed = [];
3410
+ for (const field of fields) {
3411
+ const from = String(a.metadata[field] ?? "");
3412
+ const to = String(b.metadata[field] ?? "");
3413
+ if (from !== to) changed.push(`${field}: ${from} \u2192 ${to}`);
3414
+ }
3415
+ return { id: this.id, title: this.title, added: [], removed: [], changed };
3416
+ }
3417
+ };
3418
+ var COMPARATORS = [
3419
+ toolingComparator,
3420
+ configFilesComparator,
3421
+ packageJsonComparator,
3422
+ structureComparator,
3423
+ metadataComparator
3424
+ ];
3425
+ function sortSection(section) {
3426
+ return {
3427
+ ...section,
3428
+ added: [...section.added].sort(),
3429
+ removed: [...section.removed].sort(),
3430
+ changed: [...section.changed].sort()
3431
+ };
3432
+ }
3433
+ function compareBundles(a, b) {
3434
+ return { sections: COMPARATORS.map((c) => c.compare(a, b)) };
3435
+ }
3436
+ function sectionHasChanges(section) {
3437
+ return section.added.length > 0 || section.removed.length > 0 || section.changed.length > 0;
3438
+ }
3439
+ function comparisonHasChanges(comparison) {
3440
+ return comparison.sections.some(sectionHasChanges);
3441
+ }
3442
+
3443
+ // src/commands/compare.ts
3444
+ async function resolveBundle(input) {
3445
+ const resolved = path15.resolve(input);
3446
+ if (!await fs13.pathExists(resolved)) {
3447
+ throw new ReplicaxError(`Path not found: ${input}`);
3448
+ }
3449
+ try {
3450
+ const dir = await resolveProfileDir(input);
3451
+ const bundle2 = await loadBundle(dir);
3452
+ return { bundle: bundle2, label: `${bundle2.profile.name} (profile)` };
3453
+ } catch {
3454
+ }
3455
+ const stat = await fs13.stat(resolved);
3456
+ if (!stat.isDirectory()) {
3457
+ throw new ReplicaxError(`Cannot compare "${input}": not a profile or a project directory.`, [
3458
+ "Pass a project folder or a directory containing a .replicax profile."
3459
+ ]);
3460
+ }
3461
+ const scan = await scanProject(resolved);
3462
+ const bundle = buildBundle({
3463
+ name: path15.basename(resolved) || "project",
3464
+ tooling: scan.tooling,
3465
+ structure: scan.structure,
3466
+ metadata: scan.metadata
3467
+ });
3468
+ return { bundle, label: `${path15.basename(resolved)} (scanned)` };
3469
+ }
3470
+ async function compareCommand(source, target, options) {
3471
+ const [a, b] = await Promise.all([resolveBundle(source), resolveBundle(target)]);
3472
+ const comparison = compareBundles(a.bundle, b.bundle);
3473
+ if (options.json) {
3474
+ logger.out(JSON.stringify({ source: a.label, target: b.label, ...comparison }, null, 2));
3475
+ return;
3476
+ }
3477
+ logger.out(pc.bold(`Comparing ${a.label} \u2192 ${b.label}`));
3478
+ logger.out("");
3479
+ if (!comparisonHasChanges(comparison)) {
3480
+ logger.out("No differences.");
3481
+ return;
3482
+ }
3483
+ printGroup("Added", collect(comparison, "added"), pc.green("+"));
3484
+ printGroup("Removed", collect(comparison, "removed"), pc.red("-"));
3485
+ printGroup("Changed", collect(comparison, "changed"), pc.yellow("~"));
3486
+ }
3487
+ function collect(comparison, bucket) {
3488
+ const out = [];
3489
+ for (const section of comparison.sections) {
3490
+ for (const item of section[bucket]) {
3491
+ out.push(`${item} ${pc.dim(`(${section.title})`)}`);
3492
+ }
3493
+ }
3494
+ return out;
3495
+ }
3496
+ function printGroup(label, items, marker) {
3497
+ if (items.length === 0) return;
3498
+ logger.out(pc.bold(`${label}:`));
3499
+ for (const item of items) logger.out(` ${marker} ${item}`);
3500
+ logger.out("");
3501
+ }
3502
+
3503
+ // src/commands/audit.ts
3504
+ import path16 from "path";
3505
+
3506
+ // src/core/audit/rules.ts
3507
+ function detected(ctx, ids) {
3508
+ const present = new Set(ctx.detections.map((d) => d.id));
3509
+ return ids.some((id) => present.has(id));
3510
+ }
3511
+ var AUDIT_RULES = [
3512
+ {
3513
+ id: "linting",
3514
+ title: "Linting",
3515
+ weight: 15,
3516
+ category: "quality",
3517
+ passes: (c) => detected(c, ["eslint", "biome"]),
3518
+ recommendation: "Add ESLint to catch problems with static analysis."
3519
+ },
3520
+ {
3521
+ id: "formatting",
3522
+ title: "Formatting",
3523
+ weight: 10,
3524
+ category: "quality",
3525
+ passes: (c) => detected(c, ["prettier", "biome"]),
3526
+ recommendation: "Add Prettier to keep formatting consistent."
3527
+ },
3528
+ {
3529
+ id: "testing",
3530
+ title: "Testing",
3531
+ weight: 20,
3532
+ category: "quality",
3533
+ passes: (c) => detected(c, ["vitest", "jest", "playwright", "cypress"]),
3534
+ recommendation: "Add a test runner such as Vitest or Jest."
3535
+ },
3536
+ {
3537
+ id: "git-hooks",
3538
+ title: "Git hooks",
3539
+ weight: 10,
3540
+ category: "quality",
3541
+ passes: (c) => detected(c, ["husky", "lefthook"]),
3542
+ recommendation: "Add Husky to run checks before each commit."
3543
+ },
3544
+ {
3545
+ id: "ci",
3546
+ title: "CI/CD",
3547
+ weight: 20,
3548
+ category: "delivery",
3549
+ passes: (c) => detected(c, ["github-actions", "gitlab-ci", "circleci", "jenkins", "azure-pipelines"]),
3550
+ recommendation: "Add a CI pipeline (e.g. GitHub Actions) to run checks on every push."
3551
+ },
3552
+ {
3553
+ id: "containerization",
3554
+ title: "Containerization",
3555
+ weight: 10,
3556
+ category: "delivery",
3557
+ passes: (c) => detected(c, ["docker", "docker-compose"]),
3558
+ recommendation: "Add a Dockerfile to containerize the application."
3559
+ },
3560
+ {
3561
+ id: "typescript",
3562
+ title: "TypeScript",
3563
+ weight: 10,
3564
+ category: "quality",
3565
+ passes: (c) => detected(c, ["typescript"]),
3566
+ recommendation: "Adopt TypeScript for type safety."
3567
+ },
3568
+ {
3569
+ id: "commit-linting",
3570
+ title: "Commit linting",
3571
+ weight: 3,
3572
+ category: "quality",
3573
+ passes: (c) => detected(c, ["commitlint"]),
3574
+ recommendation: "Add Commitlint to standardize commit messages."
3575
+ },
3576
+ {
3577
+ id: "staged-linting",
3578
+ title: "Staged-file linting",
3579
+ weight: 2,
3580
+ category: "quality",
3581
+ passes: (c) => detected(c, ["lint-staged"]),
3582
+ recommendation: "Add lint-staged to lint only changed files."
3583
+ }
3584
+ ];
3585
+
3586
+ // src/core/audit/engine.ts
3587
+ function runAudit(ctx, rules = AUDIT_RULES) {
3588
+ const evaluated = rules.map((rule) => ({
3589
+ id: rule.id,
3590
+ title: rule.title,
3591
+ category: rule.category,
3592
+ weight: rule.weight,
3593
+ passed: rule.passes(ctx),
3594
+ recommendation: rule.recommendation
3595
+ }));
3596
+ const totalWeight = evaluated.reduce((sum, r) => sum + r.weight, 0);
3597
+ const passedWeight = evaluated.filter((r) => r.passed).reduce((sum, r) => sum + r.weight, 0);
3598
+ const score = totalWeight === 0 ? 100 : Math.round(passedWeight / totalWeight * 100);
3599
+ const failed = evaluated.filter((r) => !r.passed);
3600
+ return {
3601
+ score,
3602
+ maxScore: 100,
3603
+ rules: evaluated,
3604
+ missing: failed.map((r) => r.title),
3605
+ recommendations: failed.map((r) => r.recommendation)
3606
+ };
3607
+ }
3608
+
3609
+ // src/commands/audit.ts
3610
+ async function buildContext(options) {
3611
+ if (options.profile) {
3612
+ const dir = await resolveProfileDir(options.profile);
3613
+ const bundle = await loadBundle(dir);
3614
+ return {
3615
+ ctx: {
3616
+ detections: bundle.metadata.detections ?? [],
3617
+ metadata: bundle.metadata,
3618
+ tooling: bundle.tooling
3619
+ },
3620
+ source: `profile "${bundle.profile.name}"`
3621
+ };
3622
+ }
3623
+ const root = path16.resolve(options.path ?? process.cwd());
3624
+ const scan = await scanProject(root);
3625
+ return {
3626
+ ctx: { detections: scan.detections, metadata: scan.metadata, tooling: scan.tooling },
3627
+ source: path16.basename(root) || "project"
3628
+ };
3629
+ }
3630
+ async function auditCommand(options) {
3631
+ const { ctx, source } = await buildContext(options);
3632
+ const result = runAudit(ctx);
3633
+ if (options.json) {
3634
+ logger.out(JSON.stringify(result, null, 2));
3635
+ return;
3636
+ }
3637
+ logger.out(pc.bold(`Project Score: ${result.score}/${result.maxScore}`));
3638
+ logger.out(pc.dim(`Audited ${source}`));
3639
+ logger.out("");
3640
+ for (const rule of result.rules) {
3641
+ logger.out(statusLine(rule.passed, rule.title));
3642
+ }
3643
+ if (result.missing.length === 0) {
3644
+ logger.out("");
3645
+ logger.out(pc.green("All checks passed."));
3646
+ return;
3647
+ }
3648
+ logger.out("");
3649
+ logger.out(pc.bold("Missing:"));
3650
+ for (const item of result.missing) logger.out(` - ${item}`);
3651
+ logger.out("");
3652
+ logger.out(pc.bold("Recommendations:"));
3653
+ for (const rec of result.recommendations) logger.out(` - ${rec}`);
3654
+ }
3655
+
2274
3656
  // src/index.ts
2275
3657
  function packageVersion() {
2276
3658
  try {
@@ -2311,12 +3693,15 @@ program.command("init-skill").description(
2311
3693
  "Generate an AI assistant skill from the detected tech stack (uses your configured AI)"
2312
3694
  ).option("--target <ai>", `Target AI assistant: ${SKILL_TARGET_IDS.join("|")}`).option("--name <name>", "Name the skill (defaults to the project folder name)").option("--provider <ai>", "Force the AI provider: claude|openai|gemini (default: auto-detect)").option("--model <id>", "Override the API model id for the chosen provider").option("--no-ai", "Skip the AI provider and use the built-in deterministic template").option("--dry-run", "Preview the skill without writing (no AI call)").option("--force", "Overwrite existing skill files").option("--verbose", "Show every detected file").action(action(initSkillCommand));
2313
3695
  program.command("extract").argument("<repo>", "GitHub repo: owner/repo, a github.com URL, or owner/repo#branch").description("Extract a ReplicaX profile from a remote GitHub repository").option("--ref <ref>", "Branch, tag, or commit to fetch (default: the repo default branch)").option("--name <name>", "Name the profile (defaults to the repo name)").option("--out <dir>", "Directory to write the .replicax profile into (default: current dir)").option("--dry-run", "Preview what would be captured without writing").option("--verbose", "Show every detected file").action(action(extractCommand));
2314
- program.command("create").argument("<project-name>", "Directory/name for the new project").description("Create a new project from a profile").option("--profile <path>", "Use a profile from a custom path").option("--skip-install", "Do not run the package manager install step").option("--dry-run", "Preview the output without writing").option("--force", "Overwrite conflicting files without prompting").option("--verbose", "Show every written file").action(action(createCommand));
3696
+ program.command("create").argument("<project-name>", "Directory/name for the new project").description("Create a new project from a profile").option("--profile <path>", "Use a profile from a custom path").option("--skip-install", "Do not run the package manager install step").option("--install", "Install deps even for imported/remote (untrusted) profiles").option("--dry-run", "Preview the output without writing").option("--force", "Overwrite conflicting files without prompting").option("--verbose", "Show every written file").action(action(createCommand));
2315
3697
  program.command("sync").description("Update the profile from the current project state").option("--diff", "Show a detailed list of what changed").option("--force", "Rewrite the profile even if nothing changed").option("--verbose", "Show every detected file").action(action(syncCommand));
2316
3698
  program.command("inspect").description("Display captured configuration and structure").option("--json", "Output as JSON").option("--section <section>", "Inspect one section: profile|tooling|structure|metadata").option("--profile <path>", "Inspect a profile at a custom path").action(action(inspectCommand));
2317
3699
  program.command("validate").description("Check profile schema and integrity").option("--profile <path>", "Validate a profile at a custom path").action(action(validateCommand));
2318
3700
  program.command("export").description("Export the profile as a portable .tar.gz archive").option("--out <file>", "Output archive path").option("--profile <path>", "Export a profile from a custom path").action(action(exportCommand));
2319
3701
  program.command("import").argument("<archive>", "Path to a .tar.gz profile archive").description("Import a portable profile archive into .replicax/").option("--force", "Overwrite an existing profile").action(action(importCommand));
3702
+ program.command("doctor").description("Check which developer tools are installed locally").option("--json", "Output as JSON").action(action(doctorCommand));
3703
+ program.command("compare").argument("<source>", "A profile path or project directory").argument("<target>", "A profile path or project directory").description("Compare two profiles (or projects): tooling, config, structure, metadata").option("--json", "Output as JSON").action(action(compareCommand));
3704
+ program.command("audit").description("Score a project setup against best practices and recommend improvements").option("--path <dir>", "Directory to audit (default: current dir)").option("--profile <path>", "Audit a stored profile instead of scanning").option("--json", "Output as JSON").action(action(auditCommand));
2320
3705
  if (process.argv.slice(2).length === 0) {
2321
3706
  program.outputHelp();
2322
3707
  process.exit(0);