@iamsaroj/replicax 0.0.4 → 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 +102 -82
  2. package/dist/index.js +325 -115
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -5,23 +5,25 @@
5
5
  <h3><em>Copy the setup, not the code.</em></h3>
6
6
 
7
7
  <p>
8
- Extract a project's entire development environment <strong>tooling, folder structure, and conventions</strong>
8
+ Extract a project's entire development environment - <strong>tooling, folder structure, and conventions</strong>
9
9
  into a portable profile, then recreate it anywhere in seconds.<br/>
10
10
  None of your business code. None of your secrets. Just the setup.
11
11
  </p>
12
12
 
13
13
  [![npm](https://img.shields.io/badge/npm-%40iamsaroj%2Freplicax-CB3837?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@iamsaroj/replicax)
14
+ [![CI](https://img.shields.io/github/actions/workflow/status/khanalsaroj/replicaX/ci.yml?branch=main&style=flat-square&label=CI&logo=github)](https://github.com/khanalsaroj/replicaX/actions/workflows/ci.yml)
15
+ [![Release](https://img.shields.io/github/actions/workflow/status/khanalsaroj/replicaX/release.yml?branch=main&style=flat-square&label=release&logo=github)](https://github.com/khanalsaroj/replicaX/actions/workflows/release.yml)
14
16
  [![Node](https://img.shields.io/badge/Node-%E2%89%A5%2020-339933?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org)
15
17
  [![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org)
16
18
  ![ESM](https://img.shields.io/badge/Module-ESM-F7DF1E?style=flat-square&logo=javascript&logoColor=black)
17
19
  [![License](https://img.shields.io/badge/License-MIT-3FB950?style=flat-square)](LICENSE)
18
20
 
19
21
  <sub>
20
- <a href="#quick-start"><b>Quick start</b></a> &nbsp;•&nbsp;
21
- <a href="#commands"><b>Commands</b></a> &nbsp;•&nbsp;
22
- <a href="#what-gets-captured"><b>What gets captured</b></a> &nbsp;•&nbsp;
23
- <a href="#security"><b>Security</b></a> &nbsp;•&nbsp;
24
- <a href="#how-it-works"><b>How it works</b></a> &nbsp;•&nbsp;
22
+ <a href="#quick-start"><b>Quick start</b></a> &nbsp;|&nbsp;
23
+ <a href="#commands"><b>Commands</b></a> &nbsp;|&nbsp;
24
+ <a href="#what-gets-captured"><b>What gets captured</b></a> &nbsp;|&nbsp;
25
+ <a href="#security"><b>Security</b></a> &nbsp;|&nbsp;
26
+ <a href="#how-it-works"><b>How it works</b></a> &nbsp;|&nbsp;
25
27
  <a href="#faq"><b>FAQ</b></a>
26
28
  </sub>
27
29
 
@@ -29,14 +31,14 @@
29
31
 
30
32
  ---
31
33
 
32
- > **ReplicaX captures the _setup_ of a project the parts you copypaste between every new repo and leaves
33
- the _implementation_ behind.**
34
+ > **ReplicaX captures the _setup_ of a project - the parts you copy-paste between every new repo - and leaves
35
+ > the _implementation_ behind.**
34
36
 
35
37
  Every new project starts the same way: copy `tsconfig.json`, port the ESLint and Prettier config, recreate the
36
- Dockerfiles, readd the CI workflow, rebuild `src/`'s folder layout. It's slow, errorprone, and quietly drifts out of
38
+ Dockerfiles, re-add the CI workflow, rebuild `src/`'s folder layout. It's slow, error-prone, and quietly drifts out of
37
39
  sync across a team.
38
40
 
39
- ReplicaX captures that ritual **once** and replays it **on demand** locally, or straight from any GitHub repo.
41
+ ReplicaX captures that ritual **once** and replays it **on demand** - locally, or straight from any GitHub repo.
40
42
 
41
43
  > It is **not** a code generator, a project cloner, or a backup tool.
42
44
 
@@ -46,7 +48,7 @@ ReplicaX captures that ritual **once** and replays it **on demand** — locally,
46
48
 
47
49
  | | Without ReplicaX | With ReplicaX |
48
50
  |-----------------------|--------------------------------------------|---------------------------------|
49
- | **New project setup** | 30+ minutes of copypaste from an old repo | `replicax create my-app` |
51
+ | **New project setup** | 30+ minutes of copy-paste from an old repo | `replicax create my-app` |
50
52
  | **What you copy** | Whatever you remember to grab | A complete, validated profile |
51
53
  | **Secrets** | One stray `.env` away from a leak | Blocked unconditionally |
52
54
  | **Team consistency** | Drifts repo to repo | One shareable `.tar.gz` profile |
@@ -58,16 +60,16 @@ ReplicaX captures that ritual **once** and replays it **on demand** — locally,
58
60
 
59
61
  | | |
60
62
  |-------------------------------|------------------------------------------------------------------------------------------------------------------|
61
- | **Onecommand capture** | `init` scans the current project into a reusable profile. |
62
- | **Capture any GitHub repo** | `extract owner/repo` profiles a remote repo no clone, no `git` required. |
63
- | **Onecommand scaffold** | `create` reproduces the setup in a fresh directory. |
64
- | **AI assistant skills** | `init-skill` uses your own AI (Claude · Codex · Gemini) to author a readytouse skill for agentic coding tools. |
65
- | **`.ts` _and_ `.js` configs** | Copied byteforbyte never compiled, never executed. |
66
- | **Secretsafe by design** | `.env`, keys, and certs can never enter a profile; `.npmrc` tokens are stripped automatically. |
63
+ | **One-command capture** | `init` scans the current project into a reusable profile. |
64
+ | **Capture any GitHub repo** | `extract owner/repo` profiles a remote repo - no clone, no `git` required. |
65
+ | **One-command scaffold** | `create` reproduces the setup in a fresh directory. |
66
+ | **AI assistant skills** | `init-skill` uses your own AI (Claude / Codex / Gemini) to author a ready-to-use skill for agentic coding tools. |
67
+ | **`.ts` _and_ `.js` configs** | Copied byte-for-byte - never compiled, never executed. |
68
+ | **Secret-safe by design** | `.env`, keys, and certs can never enter a profile; `.npmrc` tokens are stripped automatically. |
67
69
  | **No business code** | Folders are recreated empty; source files are never read. |
68
- | **Stays in sync** | `sync --diff` shows what drifted; `validate` verifies integrity via SHA256. |
70
+ | **Stays in sync** | `sync --diff` shows what drifted; `validate` verifies integrity via SHA-256. |
69
71
  | **Portable & shareable** | `export` / `import` a whole profile as a single `.tar.gz`. |
70
- | **`.replicaxignore`** | gitignorestyle control over exactly what gets captured. |
72
+ | **`.replicaxignore`** | gitignore-style control over exactly what gets captured. |
71
73
 
72
74
  ---
73
75
 
@@ -86,15 +88,15 @@ pnpm add -g @iamsaroj/replicax
86
88
  ## Quick start
87
89
 
88
90
  ```bash
89
- # 1In an existing, well-configured project
91
+ # 1 In an existing, well-configured project
90
92
  cd my-project
91
- replicax init # writes a profile to .replicax/
93
+ replicax init # -> writes a profile to .replicax/
92
94
 
93
- # 2Anywhere the profile lives, scaffold a fresh project
94
- replicax create my-new-app # same setup, none of the code
95
+ # 2 Anywhere the profile lives, scaffold a fresh project
96
+ replicax create my-new-app # -> same setup, none of the code
95
97
  ```
96
98
 
97
- and here's what `init` actually shows you:
99
+ ...and here's what `init` actually shows you:
98
100
 
99
101
  ```console
100
102
  $ replicax init
@@ -136,14 +138,14 @@ my-app/
136
138
  Create a project from it with: replicax create <project-name>
137
139
  ```
138
140
 
139
- > 💡 **Tip:** run `replicax init --dry-run` first to preview exactly what would be captured nothing is written.
141
+ > 💡 **Tip:** run `replicax init --dry-run` first to preview exactly what would be captured - nothing is written.
140
142
 
141
143
  ---
142
144
 
143
145
  ## Before & After
144
146
 
145
147
  Starting from a typical **Vite + React + TypeScript** project, `replicax init && replicax create my-new-app` produces a
146
- clean skeleton same tooling, zero application code, zero secrets.
148
+ clean skeleton - same tooling, zero application code, zero secrets.
147
149
 
148
150
  <table>
149
151
  <tr>
@@ -164,14 +166,14 @@ my-project/
164
166
  ├── docker-compose.yml
165
167
  ├── .github/workflows/ci.yml
166
168
  ├── .husky/pre-commit
167
- ├── .env secret
168
- ├── package.json runtime deps
169
+ ├── .env <- secret
170
+ ├── package.json <- runtime deps
169
171
  └── src/
170
172
  ├── components/
171
- │ └── Button.tsx business code
173
+ │ └── Button.tsx <- business code
172
174
  ├── hooks/
173
175
  ├── services/
174
- │ └── UserService.ts business code
176
+ │ └── UserService.ts <- business code
175
177
  └── pages/
176
178
  </pre>
177
179
 
@@ -211,16 +213,16 @@ No `.env`. No `Button.tsx`. No `UserService.ts`. No `react` runtime dependency.
211
213
 
212
214
  | Command | What it does |
213
215
  |--------------------------------------------------------------------------------------|----------------------------------------------------|
214
- | [`replicax init`](#replicax-init) | Scan the current project profile in `.replicax/` |
216
+ | [`replicax init`](#replicax-init) | Scan the current project -> profile in `.replicax/` |
215
217
  | [`replicax extract <repo>`](#replicax-extract-repo) | Profile a **remote GitHub repo** (no clone) |
216
218
  | [`replicax create <name>`](#replicax-create-project-name) | Scaffold a new project from a profile |
217
219
  | [`replicax sync`](#replicax-sync) | Update the profile from the current project |
218
220
  | [`replicax inspect`](#replicax-inspect) | Display captured config & structure |
219
- | [`replicax validate`](#replicax-validate) | Schema + integrity (SHA256) checkCIfriendly |
221
+ | [`replicax validate`](#replicax-validate) | Schema + integrity (SHA-256) check CI-friendly |
220
222
  | [`replicax export`](#replicax-export--import) / [`import`](#replicax-export--import) | Portable `.tar.gz` profile in/out |
221
- | [`replicax init-skill`](#replicax-init-skill) | Author an AIassistant skill from your stack |
223
+ | [`replicax init-skill`](#replicax-init-skill) | Author an AI-assistant skill from your stack |
222
224
  | [`replicax doctor`](#replicax-doctor) | Check which dev tools are installed locally |
223
- | [`replicax compare <a> <b>`](#replicax-compare-source-target) | Diff two profiles/projects: tooling, config, |
225
+ | [`replicax compare <a> <b>`](#replicax-compare-source-target) | Diff two profiles/projects: tooling, config, ... |
224
226
  | [`replicax audit`](#replicax-audit) | Score a setup vs best practices + recommendations |
225
227
 
226
228
  > Every write operation accepts `--dry-run` (preview, touch nothing) and `--verbose` (list every file).
@@ -237,7 +239,7 @@ replicax init --verbose # list every detected file
237
239
 
238
240
  ### `replicax extract <repo>`
239
241
 
240
- Capture a profile from a **remote GitHub repository** instead of the current directory the same scan as `init`,
242
+ Capture a profile from a **remote GitHub repository** instead of the current directory - the same scan as `init`,
241
243
  pointed at a repo you don't even have checked out. The repo is downloaded as a tarball over the GitHub API (no `git`
242
244
  required) into a temp directory, scanned, then discarded; only the profile is kept.
243
245
 
@@ -254,27 +256,36 @@ replicax extract owner/repo --dry-run # preview, write not
254
256
  Accepts `owner/repo`, a `github.com` URL (including `/tree/<branch>` links), an ssh remote, or a `#branch` / `@tag`
255
257
  suffix.
256
258
 
257
- > **Private repos & rate limits:** set `GITHUB_TOKEN` (or `GH_TOKEN`) in your environment it's read from the
259
+ > **Private repos & rate limits:** set `GITHUB_TOKEN` (or `GH_TOKEN`) in your environment - it's read from the
258
260
  > environment, used for the one request, and **never stored**. The same secret guard applies, so a remote repo's
259
261
  `.env` /
260
262
  > keys are never captured.
261
263
 
262
264
  ### `replicax create <project-name>`
263
265
 
264
- Create a new project from a profile. Existing files trigger an interactive overwrite/skip prompt (autoskips when
265
- noninteractive).
266
+ Create a new project from a profile. Existing files trigger an interactive overwrite/skip prompt (auto-skips when
267
+ non-interactive).
266
268
 
267
269
  ```bash
268
270
  replicax create my-app
269
271
  replicax create my-app --profile ./shared/.replicax # use a profile elsewhere
270
- replicax create my-app --skip-install # don't run the package manager
272
+ replicax create my-app --skip-install # never run the package manager
273
+ replicax create my-app --install # install even for untrusted profiles
271
274
  replicax create my-app --force # overwrite conflicts, no prompt
272
275
  replicax create my-app --dry-run # preview the file plan
273
276
  ```
274
277
 
278
+ > **Dependency install is a trust boundary.** Running the package manager executes
279
+ > any `preinstall`/`postinstall` lifecycle scripts declared by the captured
280
+ > `devDependencies`. Profiles you made locally (`init`/`sync`) install by default.
281
+ > Profiles from **`extract` (remote)** or **`import` (an archive someone sent you)**
282
+ > are marked untrusted: `create` prints the package manager and the exact
283
+ > dependency list, then **stops** without installing. Review it, then run the
284
+ > install yourself or re-run with `--install`.
285
+
275
286
  ### `replicax sync`
276
287
 
277
- Rescan and update the profile to match the current project.
288
+ Re-scan and update the profile to match the current project.
278
289
 
279
290
  ```bash
280
291
  replicax sync # update, print a change summary
@@ -300,13 +311,13 @@ Tooling (14 file(s))
300
311
  │ Language & Type Checking │ tsconfig.json │ json │ 226 B │
301
312
  │ Build Tools │ vite.config.ts │ ts │ 98 B │
302
313
  │ Formatting │ .prettierrc │ other │ 63 B │
303
-
314
+ ............
304
315
  └────────────────────────────────┴──────────────────────────┴─────────┴──────────┘
305
316
  ```
306
317
 
307
318
  ### `replicax validate`
308
319
 
309
- Check the profile's schema and integrity (SHA256 checksums + path safety). Exits nonzero on failure handy in CI.
320
+ Check the profile's schema and integrity (SHA-256 checksums + path safety). Exits non-zero on failure - handy in CI.
310
321
 
311
322
  ```bash
312
323
  replicax validate
@@ -324,12 +335,12 @@ replicax import ./react-enterprise.tar.gz --force # overwrite an existing profi
324
335
 
325
336
  ### `replicax init-skill`
326
337
 
327
- Generate an AIassistant **skill** from the current project a readytouse bundle (an entry `SKILL.md` plus optional
338
+ Generate an AI-assistant **skill** from the current project - a ready-to-use bundle (an entry `SKILL.md` plus optional
328
339
  `references/`) that teaches an assistant the tech stack, the install/build/test/lint commands, the tooling, and the
329
340
  folder layout, written where your assistant looks for skills.
330
341
 
331
342
  It uses **whatever AI you already have configured**. ReplicaX prefers a locally installed CLI (reusing its login) and
332
- falls back to a provider API key from your environment it never stores credentials:
343
+ falls back to a provider API key from your environment - it never stores credentials:
333
344
 
334
345
  | Provider | CLI (preferred) | API key (fallback) | API model default |
335
346
  |----------|-----------------|-------------------------------------|--------------------|
@@ -346,22 +357,22 @@ replicax init-skill --target claude --dry-run # preview (no AI
346
357
  replicax init-skill --target claude --force # overwrite existing skill files
347
358
  ```
348
359
 
349
- **Targets** (`--target`, required) control the ondisk _format/location_: `claude` `.claude/skills/<name>/SKILL.md`,
350
- `codex` `.codex/skills/<name>/SKILL.md`, `antigravity` `.agents/skills/<name>.md`. The **provider** (autodetected,
351
- or forced with `--provider`) is the AI that _authors_ it the two are independent.
360
+ **Targets** (`--target`, required) control the on-disk _format/location_: `claude` -> `.claude/skills/<name>/SKILL.md`,
361
+ `codex` -> `.codex/skills/<name>/SKILL.md`, `antigravity` -> `.agents/skills/<name>.md`. The **provider** (auto-detected,
362
+ or forced with `--provider`) is the AI that _authors_ it - the two are independent.
352
363
 
353
364
  > **Bring your own template.** If the project root has a `SKILL.md`, `init-skill` hands it to the AI as the **base** to
354
- > refine the model preserves your headings, structure, and instructions and fills them in from the detected setup,
365
+ > refine - the model preserves your headings, structure, and instructions and fills them in from the detected setup,
355
366
  > instead of starting from scratch.
356
367
 
357
- > **Privacy:** only the project's _setup_ is sent to the provider the same safe surface ReplicaX captures (config
368
+ > **Privacy:** only the project's _setup_ is sent to the provider - the same safe surface ReplicaX captures (config
358
369
  > files, structure, `package.json` scripts/deps). Source code and secrets are never sent. With `--no-ai` (or no provider
359
370
  > configured), ReplicaX falls back to a deterministic, fully offline template.
360
371
 
361
372
  ### `replicax doctor`
362
373
 
363
- Report which developer tools are installed locally runtimes, package managers, Docker, and editors with versions.
364
- Crossplatform and readonly; a missing tool is a finding, not an error.
374
+ Report which developer tools are installed locally - runtimes, package managers, Docker, and editors - with versions.
375
+ Cross-platform and read-only; a missing tool is a finding, not an error.
365
376
 
366
377
  ```bash
367
378
  replicax doctor
@@ -383,7 +394,7 @@ Developer environment
383
394
 
384
395
  ### `replicax compare <source> <target>`
385
396
 
386
- Diff two profiles or two project directories across tooling, configuration files, `package.json`, structure, and
397
+ Diff two profiles - or two project directories - across tooling, configuration files, `package.json`, structure, and
387
398
  metadata. Each argument may be a `.replicax` profile, a directory containing one, or a plain project folder (scanned on
388
399
  the fly), so you can compare anything against anything.
389
400
 
@@ -400,12 +411,12 @@ Removed:
400
411
  - Jest (Tooling)
401
412
  Changed:
402
413
  ~ eslint.config.js (Configuration files)
403
- ~ language: javascript typescript (Metadata)
414
+ ~ language: javascript -> typescript (Metadata)
404
415
  ```
405
416
 
406
417
  ### `replicax audit`
407
418
 
408
- Score a project's setup against bestpractice rules linting, formatting, testing, git hooks, CI/CD, containerization
419
+ Score a project's setup against best-practice rules - linting, formatting, testing, git hooks, CI/CD, containerization
409
420
  and get concrete recommendations for what's missing. Scans the current directory by default, or evaluates a stored
410
421
  profile with `--profile`.
411
422
 
@@ -433,7 +444,7 @@ Recommendations:
433
444
  ```
434
445
 
435
446
  > `compare` and `audit` build on the detection engine: every scan now reports the **detected stack** (React, TypeScript,
436
- > Docker, GitHub Actions, ) with a confidence score, persisted in the profile and viewable via `inspect --section
447
+ > Docker, GitHub Actions, ...) with a confidence score, persisted in the profile and viewable via `inspect --section
437
448
  > detections`.
438
449
 
439
450
  ---
@@ -445,10 +456,10 @@ Recommendations:
445
456
  | TS/JS configs, ESLint, Prettier | Application source (components, services, controllers) |
446
457
  | Vite / Webpack / Rollup / esbuild | Runtime `dependencies` in `package.json` |
447
458
  | Tailwind / PostCSS | `.env*`, `*.pem`, `*.key`, certificates |
448
- | Docker, CI/CD (Actions, GitLab, CircleCI, Jenkins) | `node_modules/`, `dist/`, `build/`, `coverage/`, `.next/`, |
459
+ | Docker, CI/CD (Actions, GitLab, CircleCI, Jenkins) | `node_modules/`, `dist/`, `build/`, `coverage/`, `.next/`, ... |
449
460
  | `.editorconfig`, Husky hooks | IDE folders (`.vscode/`, `.idea/`, `.vs/`, `.fleet/`, `.zed/`) |
450
461
  | Test configs (Vitest/Jest/Playwright/Cypress) | Anything matched by `.replicaxignore` |
451
- | Monorepo files, commitlint/lintstaged/release/knip | |
462
+ | Monorepo files, commitlint/lint-staged/release/knip | |
452
463
  | JVM build (Maven/Gradle) + Spring `application.*` | Compiled output (`target/`, `*.class`), the gradle wrapper JAR |
453
464
  | Folder hierarchy (directories only) | Folder _contents_ |
454
465
 
@@ -456,7 +467,7 @@ Recommendations:
456
467
  config blocks like `lint-staged` are kept. Runtime `dependencies` are deliberately dropped (that's your application),
457
468
  and the new project's name is stamped in on `create`.
458
469
 
459
- Both `.ts` and `.js` config variants work because ReplicaX copies them **verbatim**it never needs to compile or
470
+ Both `.ts` and `.js` config variants work because ReplicaX copies them **verbatim** it never needs to compile or
460
471
  execute a config to capture it.
461
472
 
462
473
  ---
@@ -466,23 +477,32 @@ execute a config to capture it.
466
477
  ReplicaX treats secret exclusion as a **hard guarantee, not a best effort.**
467
478
 
468
479
  > 🛡️ **Secrets are never captured.** `.env`, `.env.*`, `*.pem`, `*.key`, `*.crt`, SSH keys, and friends are blocked *
469
- *unconditionally** this cannot be overridden by configuration.
480
+ *unconditionally** - this cannot be overridden by configuration.
470
481
 
471
- > 🧼 **`.npmrc` is sanitized.** It's a legitimate setup file, but auth tokens (`_authToken`, `_password`, ) are stripped
482
+ > 🧼 **`.npmrc` is sanitized.** It's a legitimate setup file, but auth tokens (`_authToken`, `_password`, ...) are stripped
472
483
  > out before it enters a profile.
473
484
 
474
485
  > 🚧 **No path escapes.** Every path read from a profile (or from an AI response) is validated against traversal (`..`)
475
486
  > and absolute paths before anything is written, so a malicious profile can never write outside its target directory.
476
- `validate` rechecks this.
487
+ > `validate` re-checks this.
488
+
489
+ > 📦 **Archive extraction is a trust boundary.** An imported `.tar.gz` is validated before a single byte is written:
490
+ > entries that escape the target (`..`, absolute paths), symlinks/hardlinks, and device entries are rejected, and the
491
+ > archive is capped on compressed size, uncompressed size, file count, and per-file size, so a tar bomb is aborted up
492
+ > front. The same guard (with links skipped) protects the GitHub tarball that `extract` downloads.
493
+
494
+ > 🧯 **Install is opt-in for untrusted profiles.** Because `npm install` runs dependency lifecycle scripts, `create`
495
+ > will not auto-install for an `extract`ed or `import`ed profile - it prints the dependency list and waits for you to
496
+ > review and pass `--install`. See [`replicax create`](#replicax-create-project-name).
477
497
 
478
- The same guarantees apply to `extract` a remote repo's secrets are filtered exactly as a local project's are.
498
+ The same guarantees apply to `extract` - a remote repo's secrets are filtered exactly as a local project's are.
479
499
 
480
500
  ---
481
501
 
482
- ## Configuration `.replicaxignore` and `.replicaxinclude`
502
+ ## Configuration - `.replicaxignore` and `.replicaxinclude`
483
503
 
484
504
  **`.replicaxignore`** controls what gets *excluded*, with **gitignore syntax**. `init` can scaffold a starter for you.
485
- Ignored files are excluded from the profile but their parent directories are still captured for structure.
505
+ Ignored files are excluded from the profile - but their parent directories are still captured for structure.
486
506
 
487
507
  ```gitignore
488
508
  # Business logic (folders kept, contents dropped)
@@ -496,7 +516,7 @@ src/**/*.ts
496
516
 
497
517
  **`.replicaxinclude`** is the opposite: a list of **glob patterns** (one per line, `#` comments) for *extra* files to
498
518
  capture verbatim, on top of the auto-detected catalogue. Use it for config ReplicaX doesn't recognize by default
499
- (`*.toml`, a `config/` directory, an IDE file you do want shared, ). A trailing `/` means "the whole directory".
519
+ (`*.toml`, a `config/` directory, an IDE file you do want shared, ...). A trailing `/` means "the whole directory".
500
520
 
501
521
  ```gitignore
502
522
  # Capture these in addition to the auto-detected setup
@@ -505,9 +525,9 @@ config/**
505
525
  .vscode/extensions.json
506
526
  ```
507
527
 
508
- **Precedence** (highest first): the **secret guard** always wins (a secret can never be included)
509
- **`.replicaxignore`** (your excludes beat your includes) **`.replicaxinclude`** (overrides the default prune/ignore
510
- lists, so it can reach normally-skipped locations) the **built-in catalogue**.
528
+ **Precedence** (highest first): the **secret guard** always wins (a secret can never be included) ->
529
+ **`.replicaxignore`** (your excludes beat your includes) -> **`.replicaxinclude`** (overrides the default prune/ignore
530
+ lists, so it can reach normally-skipped locations) -> the **built-in catalogue**.
511
531
 
512
532
  ---
513
533
 
@@ -525,10 +545,10 @@ A profile is five required JSON files under `.replicax/`, plus an optional manif
525
545
  └── manifest.json # content-free index of artifacts (path, category, size, hash)
526
546
  ```
527
547
 
528
- All files are schemavalidated (zod) on load; `validate` additionally rechecks checksums and rejects unsafe paths.
548
+ All files are schema-validated (zod) on load; `validate` additionally re-checks checksums and rejects unsafe paths.
529
549
 
530
550
  **Schema version 2.1.0** added the detected stack (`metadata.detections`), an optional `registry` block (for future
531
- registry support), and `manifest.json` all backwardcompatible. Profiles written by older ReplicaX (2.0.0) are
551
+ registry support), and `manifest.json` - all backward-compatible. Profiles written by older ReplicaX (2.0.0) are
532
552
  **migrated automatically on load** and the manifest is synthesized when absent, so existing profiles keep working.
533
553
 
534
554
  ---
@@ -552,7 +572,7 @@ flowchart LR
552
572
 
553
573
  - **Scanner** detects config files (via a glob catalogue), the folder hierarchy, and project metadata (package manager,
554
574
  framework, language).
555
- - **Ignore engine** layers default ignores + `.replicaxignore`, with a separate, **nonoverridable secret guard**.
575
+ - **Ignore engine** layers default ignores + `.replicaxignore`, with a separate, **non-overridable secret guard**.
556
576
  - **Profile generator** assembles the bundle and computes checksums.
557
577
  - **Project generator** reproduces the setup, adapting names/paths, with a **conflict resolver** for existing files.
558
578
 
@@ -562,7 +582,7 @@ flowchart LR
562
582
 
563
583
  ```bash
564
584
  npm install
565
- npm run build # tsup dist/index.js (single ESM file, with shebang)
585
+ npm run build # tsup -> dist/index.js (single ESM file, with shebang)
566
586
  npm run typecheck # tsc --noEmit (covers src AND tests)
567
587
  npm test # vitest (real temp-dir fixtures, no mocks)
568
588
  npm run format # prettier --write .
@@ -581,15 +601,15 @@ npx vitest run -t "sanitizes a captured .npmrc"
581
601
  <br/>
582
602
 
583
603
  **Lockfile.** `package-lock.json` is maintained with **npm 10** (what CI's Node 20/22 ship with). On npm 11+, regenerate
584
- with `npx npm@10 install` when changing dependencies npm 11 resolves a different tree and will desync `npm ci`.
604
+ with `npx npm@10 install` when changing dependencies - npm 11 resolves a different tree and will desync `npm ci`.
585
605
 
586
- **Path alias.** Imports use a `@/*` `src/*` alias resolved by tsc, tsup/esbuild, and the `vite-tsconfig-paths` vitest
606
+ **Path alias.** Imports use a `@/*` -> `src/*` alias resolved by tsc, tsup/esbuild, and the `vite-tsconfig-paths` vitest
587
607
  plugin. The build bundles to one file, so the alias never reaches the published output.
588
608
 
589
- **Stack.** TypeScript 5 · Node 20+ · ESM · commander · fastglob · fsextra · ignore · zod · tar · picocolors · ora ·
590
- @inquirer/prompts · clitable3 · vitest · tsup · prettier.
609
+ **Stack.** TypeScript 5 / Node 20+ / ESM / commander / fast-glob / fs-extra / ignore / zod / tar / picocolors / ora /
610
+ @inquirer/prompts / cli-table3 / vitest / tsup / prettier.
591
611
 
592
- **Audit note.** `npm audit` flags `esbuild` (a buildtime transitive of `tsup`). The advisory concerns esbuild's dev
612
+ **Audit note.** `npm audit` flags `esbuild` (a build-time transitive of `tsup`). The advisory concerns esbuild's dev
593
613
  server, which ReplicaX never runs, and esbuild is not part of the published runtime (`dist/`). Fixing it requires a
594
614
  breaking tsup downgrade, so the toolchain is left intact.
595
615
 
@@ -608,7 +628,7 @@ No. Only configuration files and the empty folder hierarchy. Application code is
608
628
  <details>
609
629
  <summary><b>What about <code>.ts</code> config files like <code>vite.config.ts</code>?</b></summary>
610
630
  <br/>
611
- Fully supported they're copied as text, so both <code>.ts</code> and <code>.js</code> variants work without any compile step.
631
+ Fully supported - they're copied as text, so both <code>.ts</code> and <code>.js</code> variants work without any compile step.
612
632
  </details>
613
633
 
614
634
  <details>
@@ -626,22 +646,22 @@ They're part of your application, not your setup. <code>devDependencies</code>,
626
646
  <details>
627
647
  <summary><b>Does <code>extract</code> need <code>git</code> installed?</b></summary>
628
648
  <br/>
629
- No. It downloads the repo as a tarball over the GitHub API using Node's built-in <code>fetch</code>no <code>git</code> binary, no full clone.
649
+ No. It downloads the repo as a tarball over the GitHub API using Node's built-in <code>fetch</code> no <code>git</code> binary, no full clone.
630
650
  </details>
631
651
 
632
652
  <details>
633
653
  <summary><b>Is it cross-platform?</b></summary>
634
654
  <br/>
635
- YesWindows (native + WSL), macOS, and Linux.
655
+ Yes Windows (native + WSL), macOS, and Linux.
636
656
  </details>
637
657
 
638
658
  ---
639
659
 
640
660
  ## License
641
661
 
642
- **MIT**see [LICENSE](LICENSE).
662
+ **MIT** see [LICENSE](LICENSE).
643
663
 
644
664
  <div align="center">
645
665
  <br/>
646
- <sub><i>ReplicaX copy the setup, not the code.</i></sub>
666
+ <sub><i>ReplicaX - copy the setup, not the code.</i></sub>
647
667
  </div>
package/dist/index.js CHANGED
@@ -70,7 +70,7 @@ var REPLICAX_DIR = ".replicax";
70
70
  var IGNORE_FILE = ".replicaxignore";
71
71
  var INCLUDE_FILE = ".replicaxinclude";
72
72
  var ROOT_SKILL_FILE = "SKILL.md";
73
- var REPLICAX_VERSION = "2.1.0";
73
+ var REPLICAX_VERSION = "2.2.0";
74
74
  var PROFILE_FILES = {
75
75
  profile: "profile.json",
76
76
  tooling: "tooling.json",
@@ -1273,13 +1273,16 @@ function buildBundle(args) {
1273
1273
  name: args.name,
1274
1274
  description: args.description ?? args.existing.description,
1275
1275
  replicaxVersion: REPLICAX_VERSION,
1276
- 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 } : {}
1277
1279
  } : {
1278
1280
  name: args.name,
1279
1281
  version: "1.0.0",
1280
1282
  createdAt: now,
1281
1283
  replicaxVersion: REPLICAX_VERSION,
1282
- ...args.description ? { description: args.description } : {}
1284
+ ...args.description ? { description: args.description } : {},
1285
+ ...args.source ? { source: args.source } : {}
1283
1286
  };
1284
1287
  const checksum = computeChecksum(args.tooling);
1285
1288
  return {
@@ -1308,6 +1311,7 @@ var RegistrySchema = z.object({
1308
1311
  /** Where the profile originated (URL, registry name, …). */
1309
1312
  source: z.string().optional()
1310
1313
  });
1314
+ var ProfileSourceSchema = z.enum(["local", "github", "import"]);
1311
1315
  var ProfileSchema = z.object({
1312
1316
  name: z.string().min(1),
1313
1317
  version: z.string().min(1),
@@ -1315,6 +1319,8 @@ var ProfileSchema = z.object({
1315
1319
  updatedAt: z.string().optional(),
1316
1320
  replicaxVersion: z.string().min(1),
1317
1321
  description: z.string().optional(),
1322
+ /** Provenance of the captured setup (added in schema 2.2.0). */
1323
+ source: ProfileSourceSchema.optional(),
1318
1324
  /** Optional registry metadata (future registry compatibility). */
1319
1325
  registry: RegistrySchema.optional()
1320
1326
  });
@@ -1417,6 +1423,13 @@ var MIGRATIONS = [
1417
1423
  }
1418
1424
  return raw;
1419
1425
  }
1426
+ },
1427
+ {
1428
+ from: "2.1.0",
1429
+ to: "2.2.0",
1430
+ apply(raw) {
1431
+ return raw;
1432
+ }
1420
1433
  }
1421
1434
  ];
1422
1435
  var KNOWN_VERSIONS = /* @__PURE__ */ new Set([
@@ -1619,7 +1632,8 @@ async function initCommand(options) {
1619
1632
  name,
1620
1633
  tooling: scan.tooling,
1621
1634
  structure: scan.structure,
1622
- metadata: scan.metadata
1635
+ metadata: scan.metadata,
1636
+ source: "local"
1623
1637
  });
1624
1638
  reportSkippedSecrets(scan.skippedSecrets);
1625
1639
  printScanSummary(bundle);
@@ -1973,6 +1987,14 @@ async function runWithStdin(bin, args, input, timeoutMs = 12e4) {
1973
1987
  }
1974
1988
 
1975
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
+ };
1976
1998
  async function postJson(url, headers, body) {
1977
1999
  const res = await fetch(url, {
1978
2000
  method: "POST",
@@ -1981,10 +2003,33 @@ async function postJson(url, headers, body) {
1981
2003
  });
1982
2004
  if (!res.ok) {
1983
2005
  const text = await res.text().catch(() => "");
1984
- throw new Error(`HTTP ${res.status}: ${text.slice(0, 300) || res.statusText}`);
2006
+ throw new ApiHttpError(res.status, text.slice(0, 300) || res.statusText);
1985
2007
  }
1986
2008
  return res.json();
1987
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
+ }
1988
2033
  async function callAnthropic(prompt, apiKey, model) {
1989
2034
  const data = await postJson(
1990
2035
  "https://api.anthropic.com/v1/messages",
@@ -2061,7 +2106,13 @@ function apiInvoker(def, apiKey, modelOverride) {
2061
2106
  return {
2062
2107
  id: def.id,
2063
2108
  via: `${def.label} API (${model})`,
2064
- 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
+ }
2065
2116
  };
2066
2117
  }
2067
2118
  async function resolveProvider(preference, modelOverride) {
@@ -2234,7 +2285,9 @@ async function initSkillCommand(options) {
2234
2285
  }
2235
2286
  } catch (err) {
2236
2287
  aiSpinner.fail("AI generation failed");
2237
- 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.");
2238
2291
  }
2239
2292
  } else {
2240
2293
  logger.info("No configured AI provider found \u2014 using the built-in template.");
@@ -2278,14 +2331,180 @@ async function initSkillCommand(options) {
2278
2331
  }
2279
2332
 
2280
2333
  // src/commands/extract.ts
2281
- import path10 from "path";
2334
+ import path11 from "path";
2282
2335
  import ora3 from "ora";
2283
2336
 
2284
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";
2285
2344
  import os from "os";
2286
2345
  import path9 from "path";
2287
2346
  import fs8 from "fs-extra";
2288
- import { extract as tarExtract } from "tar";
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
2289
2508
  function parseGitHubRef(input) {
2290
2509
  const raw = input.trim();
2291
2510
  if (!raw) {
@@ -2355,9 +2574,9 @@ function httpError(status, slug, hasToken) {
2355
2574
  return new ReplicaxError(`GitHub returned HTTP ${status} for ${slug}.`);
2356
2575
  }
2357
2576
  async function firstSubdir(dir) {
2358
- const entries = await fs8.readdir(dir, { withFileTypes: true });
2577
+ const entries = await fs9.readdir(dir, { withFileTypes: true });
2359
2578
  for (const entry of entries) {
2360
- if (entry.isDirectory()) return path9.join(dir, entry.name);
2579
+ if (entry.isDirectory()) return path10.join(dir, entry.name);
2361
2580
  }
2362
2581
  return null;
2363
2582
  }
@@ -2381,14 +2600,14 @@ async function downloadRepo(ref) {
2381
2600
  if (!res.ok) {
2382
2601
  throw httpError(res.status, slug, Boolean(token));
2383
2602
  }
2384
- const tmpRoot = await fs8.mkdtemp(path9.join(os.tmpdir(), "replicax-extract-"));
2385
- const cleanup = () => fs8.remove(tmpRoot);
2603
+ const tmpRoot = await fs9.mkdtemp(path10.join(os2.tmpdir(), "replicax-extract-"));
2604
+ const cleanup = () => fs9.remove(tmpRoot);
2386
2605
  try {
2387
- const tarPath = path9.join(tmpRoot, "repo.tar.gz");
2388
- await fs8.writeFile(tarPath, Buffer.from(await res.arrayBuffer()));
2389
- const extractDir = path9.join(tmpRoot, "src");
2390
- await fs8.ensureDir(extractDir);
2391
- 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);
2392
2611
  const repoRoot = await firstSubdir(extractDir);
2393
2612
  if (!repoRoot) {
2394
2613
  throw new ReplicaxError(`The downloaded archive for ${slug} was empty.`);
@@ -2430,7 +2649,9 @@ async function extractCommand(repo, options) {
2430
2649
  name,
2431
2650
  tooling: scan.tooling,
2432
2651
  structure: scan.structure,
2433
- metadata: scan.metadata
2652
+ metadata: scan.metadata,
2653
+ // Captured from a remote repo we don't control — untrusted for auto-install.
2654
+ source: "github"
2434
2655
  });
2435
2656
  reportSkippedSecrets(scan.skippedSecrets);
2436
2657
  printScanSummary(bundle);
@@ -2440,7 +2661,7 @@ async function extractCommand(repo, options) {
2440
2661
  logger.info("Dry run \u2014 no files were written.");
2441
2662
  return;
2442
2663
  }
2443
- const outRoot = options.out ? path10.resolve(options.out) : process.cwd();
2664
+ const outRoot = options.out ? path11.resolve(options.out) : process.cwd();
2444
2665
  const dir = profileDir(outRoot);
2445
2666
  if (await profileExists(dir)) {
2446
2667
  logger.warn(
@@ -2457,8 +2678,7 @@ async function extractCommand(repo, options) {
2457
2678
  }
2458
2679
 
2459
2680
  // src/commands/create.ts
2460
- import path12 from "path";
2461
- import fs10 from "fs-extra";
2681
+ import path13 from "path";
2462
2682
 
2463
2683
  // src/core/conflict-resolver.ts
2464
2684
  import { select } from "@inquirer/prompts";
@@ -2500,8 +2720,8 @@ var ConflictResolver = class {
2500
2720
  };
2501
2721
 
2502
2722
  // src/core/project-generator.ts
2503
- import path11 from "path";
2504
- import fs9 from "fs-extra";
2723
+ import path12 from "path";
2724
+ import fs10 from "fs-extra";
2505
2725
  async function generateProject(options) {
2506
2726
  const { bundle, targetDir, projectName, dryRun, conflict } = options;
2507
2727
  const result = {
@@ -2511,16 +2731,16 @@ async function generateProject(options) {
2511
2731
  filesSkipped: 0,
2512
2732
  unsafeSkipped: []
2513
2733
  };
2514
- if (!dryRun) await fs9.ensureDir(targetDir);
2734
+ if (!dryRun) await fs10.ensureDir(targetDir);
2515
2735
  for (const dir of bundle.structure.directories) {
2516
2736
  const safe = safeJoinable(dir);
2517
2737
  if (!safe) {
2518
2738
  result.unsafeSkipped.push(dir);
2519
2739
  continue;
2520
2740
  }
2521
- const full = path11.join(targetDir, safe);
2522
- const existed = await fs9.pathExists(full);
2523
- if (!dryRun) await fs9.ensureDir(full);
2741
+ const full = path12.join(targetDir, safe);
2742
+ const existed = await fs10.pathExists(full);
2743
+ if (!dryRun) await fs10.ensureDir(full);
2524
2744
  if (!existed) result.dirsCreated += 1;
2525
2745
  result.entries.push({ kind: "dir", path: safe, action: existed ? "skip" : "create" });
2526
2746
  }
@@ -2544,8 +2764,8 @@ async function writeFile(relPath, content, options, result) {
2544
2764
  logger.warn(`Refusing to write unsafe path from profile: ${relPath}`);
2545
2765
  return;
2546
2766
  }
2547
- const full = path11.join(options.targetDir, safe);
2548
- const exists = await fs9.pathExists(full);
2767
+ const full = path12.join(options.targetDir, safe);
2768
+ const exists = await fs10.pathExists(full);
2549
2769
  let action2 = exists ? "overwrite" : "create";
2550
2770
  if (exists) {
2551
2771
  const decision = await options.conflict.resolve(safe);
@@ -2558,8 +2778,8 @@ async function writeFile(relPath, content, options, result) {
2558
2778
  action2 = "overwrite";
2559
2779
  }
2560
2780
  if (!options.dryRun) {
2561
- await fs9.ensureDir(path11.dirname(full));
2562
- await fs9.writeFile(full, content, "utf8");
2781
+ await fs10.ensureDir(path12.dirname(full));
2782
+ await fs10.writeFile(full, content, "utf8");
2563
2783
  }
2564
2784
  result.filesWritten += 1;
2565
2785
  result.entries.push({ kind: "file", path: safe, action: action2 });
@@ -2608,9 +2828,9 @@ async function createCommand(projectName, options) {
2608
2828
  logger.warn(`Profile integrity check found ${mismatches.length} issue(s); continuing anyway.`);
2609
2829
  logger.hint("Run `replicax validate` for details.");
2610
2830
  }
2611
- const targetDir = path12.resolve(process.cwd(), projectName);
2612
- const leafName = path12.basename(targetDir);
2613
- if (path12.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) {
2614
2834
  throw new ReplicaxError("Refusing to scaffold into the current directory.", [
2615
2835
  "Pass a new project name, e.g. `replicax create my-app`."
2616
2836
  ]);
@@ -2640,36 +2860,68 @@ async function createCommand(projectName, options) {
2640
2860
  return;
2641
2861
  }
2642
2862
  logger.hint(`Location: ${relPosix(process.cwd(), targetDir)}/`);
2643
- await maybeInstall(
2644
- bundle.metadata.packageManager,
2645
- targetDir,
2646
- options,
2647
- Boolean(bundle.tooling.packageJson)
2648
- );
2863
+ await maybeInstall(bundle, targetDir, options);
2649
2864
  logger.newline();
2650
2865
  logger.success(`Project ${pc.bold(leafName)} is ready.`);
2651
2866
  }
2652
- async function maybeInstall(manager, targetDir, options, hasPackageJson) {
2653
- if (options.skipInstall) {
2654
- logger.hint("Skipped dependency install (--skip-install).");
2655
- return;
2656
- }
2657
- if (!hasPackageJson) return;
2658
- if (manager === "unknown") {
2659
- logger.hint("No package manager detected; run your install command manually.");
2660
- return;
2661
- }
2662
- const pkgPath = path12.join(targetDir, "package.json");
2663
- const pkg = await fs10.readJson(pkgPath).catch(() => null);
2664
- if (!pkg?.devDependencies || Object.keys(pkg.devDependencies).length === 0) {
2665
- logger.hint("No dependencies to install.");
2666
- 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
+ }
2667
2924
  }
2668
- logger.newline();
2669
- logger.info(`Installing dependencies with ${manager}\u2026`);
2670
- const ok = await installDependencies(targetDir, manager);
2671
- if (ok) logger.success("Dependencies installed.");
2672
- else logger.warn("Dependency install did not complete; run it manually.");
2673
2925
  }
2674
2926
 
2675
2927
  // src/commands/sync.ts
@@ -2752,6 +3004,8 @@ async function syncCommand(options) {
2752
3004
  tooling: scan.tooling,
2753
3005
  structure: scan.structure,
2754
3006
  metadata: scan.metadata,
3007
+ // A sync re-captures the local project, so the result is locally trusted.
3008
+ source: "local",
2755
3009
  existing: existing.profile
2756
3010
  });
2757
3011
  const diff = diffBundles(existing, next);
@@ -2934,53 +3188,8 @@ async function validateCommand(options) {
2934
3188
 
2935
3189
  // src/commands/export.ts
2936
3190
  import path14 from "path";
2937
- import fs12 from "fs-extra";
2938
- import ora5 from "ora";
2939
-
2940
- // src/core/archive.ts
2941
- import os2 from "os";
2942
- import path13 from "path";
2943
3191
  import fs11 from "fs-extra";
2944
- import { create as tarCreate, extract as tarExtract2 } from "tar";
2945
- async function exportProfile(profileDirectory, outPath) {
2946
- const resolvedOut = path13.resolve(outPath);
2947
- await fs11.ensureDir(path13.dirname(resolvedOut));
2948
- const parent = path13.dirname(profileDirectory);
2949
- const base = path13.basename(profileDirectory);
2950
- await tarCreate(
2951
- {
2952
- gzip: true,
2953
- file: resolvedOut,
2954
- cwd: parent,
2955
- // tar strips leading "/" and ".." by default, so extraction stays scoped.
2956
- portable: true
2957
- },
2958
- [base]
2959
- );
2960
- }
2961
- async function extractToTemp(archivePath) {
2962
- const resolved = path13.resolve(archivePath);
2963
- if (!await fs11.pathExists(resolved)) {
2964
- throw new Error(`Archive not found: ${archivePath}`);
2965
- }
2966
- const tmp = await fs11.mkdtemp(path13.join(os2.tmpdir(), "replicax-import-"));
2967
- await tarExtract2({ file: resolved, cwd: tmp, strip: 0 });
2968
- return tmp;
2969
- }
2970
- async function findProfileRoot(dir) {
2971
- const hasProfile = async (d) => fs11.pathExists(path13.join(d, PROFILE_FILES.profile));
2972
- if (await hasProfile(dir)) return dir;
2973
- const entries = await fs11.readdir(dir, { withFileTypes: true });
2974
- for (const entry of entries) {
2975
- if (entry.isDirectory()) {
2976
- const candidate = path13.join(dir, entry.name);
2977
- if (await hasProfile(candidate)) return candidate;
2978
- }
2979
- }
2980
- return null;
2981
- }
2982
-
2983
- // src/commands/export.ts
3192
+ import ora5 from "ora";
2984
3193
  async function exportCommand(options) {
2985
3194
  const dir = options.profile ? await resolveProfileDir(options.profile) : profileDir(process.cwd());
2986
3195
  if (!await profileExists(dir)) {
@@ -2993,7 +3202,7 @@ async function exportCommand(options) {
2993
3202
  const spinner = ora5({ text: "Packaging profile\u2026" }).start();
2994
3203
  await exportProfile(dir, outPath);
2995
3204
  spinner.stop();
2996
- const { size } = await fs12.stat(outPath);
3205
+ const { size } = await fs11.stat(outPath);
2997
3206
  logger.success(
2998
3207
  `Exported "${bundle.profile.name}" \u2192 ${path14.relative(process.cwd(), outPath)} (${formatBytes(size)})`
2999
3208
  );
@@ -3001,7 +3210,7 @@ async function exportCommand(options) {
3001
3210
  }
3002
3211
 
3003
3212
  // src/commands/import.ts
3004
- import fs13 from "fs-extra";
3213
+ import fs12 from "fs-extra";
3005
3214
  import ora6 from "ora";
3006
3215
  import { confirm as confirm2 } from "@inquirer/prompts";
3007
3216
  async function importCommand(archivePath, options) {
@@ -3017,6 +3226,7 @@ async function importCommand(archivePath, options) {
3017
3226
  throw new ReplicaxError("The archive does not contain a ReplicaX profile.");
3018
3227
  }
3019
3228
  const bundle = await loadBundle(source);
3229
+ bundle.profile.source = "import";
3020
3230
  spinner.succeed(`Validated profile "${bundle.profile.name}"`);
3021
3231
  const dest = profileDir(process.cwd());
3022
3232
  if (await profileExists(dest)) {
@@ -3029,7 +3239,7 @@ async function importCommand(archivePath, options) {
3029
3239
  "Re-run with --force to overwrite it."
3030
3240
  ]);
3031
3241
  }
3032
- await fs13.remove(dest);
3242
+ await fs12.remove(dest);
3033
3243
  }
3034
3244
  await saveBundle(dest, bundle);
3035
3245
  logger.newline();
@@ -3038,7 +3248,7 @@ async function importCommand(archivePath, options) {
3038
3248
  );
3039
3249
  logger.hint("Create a project with: replicax create <project-name>");
3040
3250
  } finally {
3041
- await fs13.remove(tmp).catch(() => void 0);
3251
+ await fs12.remove(tmp).catch(() => void 0);
3042
3252
  }
3043
3253
  }
3044
3254
 
@@ -3123,7 +3333,7 @@ async function doctorCommand(options) {
3123
3333
 
3124
3334
  // src/commands/compare.ts
3125
3335
  import path15 from "path";
3126
- import fs14 from "fs-extra";
3336
+ import fs13 from "fs-extra";
3127
3337
 
3128
3338
  // src/core/compare.ts
3129
3339
  function detectionsOf(bundle) {
@@ -3233,7 +3443,7 @@ function comparisonHasChanges(comparison) {
3233
3443
  // src/commands/compare.ts
3234
3444
  async function resolveBundle(input) {
3235
3445
  const resolved = path15.resolve(input);
3236
- if (!await fs14.pathExists(resolved)) {
3446
+ if (!await fs13.pathExists(resolved)) {
3237
3447
  throw new ReplicaxError(`Path not found: ${input}`);
3238
3448
  }
3239
3449
  try {
@@ -3242,7 +3452,7 @@ async function resolveBundle(input) {
3242
3452
  return { bundle: bundle2, label: `${bundle2.profile.name} (profile)` };
3243
3453
  } catch {
3244
3454
  }
3245
- const stat = await fs14.stat(resolved);
3455
+ const stat = await fs13.stat(resolved);
3246
3456
  if (!stat.isDirectory()) {
3247
3457
  throw new ReplicaxError(`Cannot compare "${input}": not a profile or a project directory.`, [
3248
3458
  "Pass a project folder or a directory containing a .replicax profile."
@@ -3483,7 +3693,7 @@ program.command("init-skill").description(
3483
3693
  "Generate an AI assistant skill from the detected tech stack (uses your configured AI)"
3484
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));
3485
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));
3486
- 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));
3487
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));
3488
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));
3489
3699
  program.command("validate").description("Check profile schema and integrity").option("--profile <path>", "Validate a profile at a custom path").action(action(validateCommand));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iamsaroj/replicax",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Copy the setup, not the code. Extract a project's tooling, structure, and conventions and recreate them in new projects.",
5
5
  "type": "module",
6
6
  "license": "MIT",