diff --git a/README.md b/README.md index a56f0d9..be7dab6 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ See [SECURITY.md](SECURITY.md), the [threat model](docs/architecture/threat-mode `stellarforge add`, plugin architecture, remote template registries, and Mainnet deployment are **not implemented** and remain post-v1 or separately reviewed candidates. -See [Quick Start](docs/guides/quick-start.md), [Command Reference](docs/reference/commands.md), [Configuration](docs/reference/configuration.md), and [Troubleshooting](docs/reference/troubleshooting.md). +See [Quick Start](docs/guides/quick-start.md), [Command Reference](docs/reference/commands.md), [Configuration](docs/reference/configuration.md), [Troubleshooting](docs/reference/troubleshooting.md#find-your-problem), [Platform Support](docs/reference/platform-support.md), and [Exit Codes](docs/reference/exit-codes.md). --- diff --git a/docs/guides/installation.md b/docs/guides/installation.md index 8201753..cce1400 100644 --- a/docs/guides/installation.md +++ b/docs/guides/installation.md @@ -83,3 +83,7 @@ The release workflow is designed around: The npm namespace, Trusted Publisher, protected GitHub environment, and Dependency Graph are configured. `NPM_PUBLISH_ENABLED` remains `false` as a deliberate kill switch. REL-001 remains open only for the first approved protected OIDC publication and its npm/tag/GitHub Release/provenance verification. See [Release Process](../contributing/release-process.md) for the complete release contract. + +## Troubleshooting + +If installation or local setup fails, see [Troubleshooting](../reference/troubleshooting.md#installation-or-setup-fails). Runtime and operating-system expectations are in [Platform Support](../reference/platform-support.md), and exit codes are defined in the [Exit Code Policy](../reference/exit-codes.md). diff --git a/docs/guides/quick-start.md b/docs/guides/quick-start.md index e97eabc..d86e8fb 100644 --- a/docs/guides/quick-start.md +++ b/docs/guides/quick-start.md @@ -23,6 +23,8 @@ stellarforge new my-stack --template full-stack Supported template IDs are `basic-app`, `full-stack`, `smart-contract`, and `api-service`. +If creation fails, see [`new` troubleshooting](../reference/troubleshooting.md#stellarforge-new-cannot-create-the-project) and [Project name is rejected](../reference/troubleshooting.md#project-name-is-rejected). + ## 2. Validate your environment ```bash @@ -31,6 +33,8 @@ stellarforge doctor The command checks the supported local toolchain and prints remediation guidance. A failed required diagnostic exits with code `5`. +If diagnostics fail, see [`doctor` troubleshooting](../reference/troubleshooting.md#stellarforge-doctor-reports-failures). + ## 3. Run local development From a supported generated Node.js project: @@ -41,6 +45,8 @@ stellarforge dev StellarForge looks for a supported npm development script: `dev`, then `start`, then `start:backend`. +If no script is found, see [`dev` troubleshooting](../reference/troubleshooting.md#stellarforge-dev-says-no-supported-script-exists). + ## 4. Run tests ```bash @@ -49,6 +55,8 @@ stellarforge test Node.js projects use `npm run test`; Cargo projects use `cargo test`. +If no workflow is found, see [`test` troubleshooting](../reference/troubleshooting.md#stellarforge-test-cannot-find-a-workflow). + ## 5. Configure Testnet deployment For a smart-contract project, create `stellarforge.config.json` at the project root: @@ -65,6 +73,8 @@ The identity must already exist in Stellar CLI identity management. Do not place See the [Configuration Reference](../reference/configuration.md). +If the file is rejected, see [Configuration troubleshooting](../reference/troubleshooting.md#configuration-file-is-rejected). + ## 6. Deploy to Testnet ```bash @@ -79,9 +89,13 @@ stellarforge deploy --network testnet --source deployer The MVP deployment path is Testnet-only. Mainnet deployment is not implemented. +If deployment fails, see [network required](../reference/troubleshooting.md#deployment-network-is-required), [Mainnet or Futurenet rejection](../reference/troubleshooting.md#deployment-rejects-mainnet-or-futurenet), [identity rejected](../reference/troubleshooting.md#deployment-identity-is-rejected), or [Stellar CLI deployment fails](../reference/troubleshooting.md#stellar-cli-deployment-fails). + ## Next references - [Command Reference](../reference/commands.md) -- [Troubleshooting](../reference/troubleshooting.md) +- [Troubleshooting](../reference/troubleshooting.md#find-your-problem) +- [Installation](installation.md) +- [Platform Support](../reference/platform-support.md) - [Exit Code Policy](../reference/exit-codes.md) - [Security Policy](../../SECURITY.md) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 3e2a57e..43f5339 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -15,7 +15,7 @@ This reference documents the implemented StellarForge CLI MVP command surface. | `stellarforge deploy` | Deploy a supported smart-contract project to Stellar Testnet | Implemented | | `stellarforge add` | Add modules/plugins | Not implemented / post-v1 candidate | -See also [Installation](../guides/installation.md), [Quick Start](../guides/quick-start.md), [Configuration](configuration.md), [Exit Codes](exit-codes.md), [Troubleshooting](troubleshooting.md), and the repository [security guidance](../../SECURITY.md). +See also [Installation](../guides/installation.md), [Quick Start](../guides/quick-start.md), [Configuration](configuration.md), [Exit Codes](exit-codes.md), [Troubleshooting](troubleshooting.md#find-your-problem), [Platform Support](platform-support.md), and the repository [security guidance](../../SECURITY.md). ## Global command behavior @@ -83,6 +83,8 @@ See [Template Generation Safety](template-generation.md). These are validation failures and exit with code `2`. +See [Troubleshooting](troubleshooting.md#stellarforge-new-cannot-create-the-project) and [Project name is rejected](troubleshooting.md#project-name-is-rejected). + ## `stellarforge doctor` Runs the registered development-environment diagnostics. @@ -106,6 +108,8 @@ Warnings do not by themselves make the command fail. The command does not print complete sensitive environment values. +See [Troubleshooting](troubleshooting.md#stellarforge-doctor-reports-failures) when diagnostics fail. + ## `stellarforge dev` Starts the supported local-development workflow for a generated Node.js StellarForge project. @@ -137,6 +141,8 @@ Child stdout/stderr are streamed with process labels. Validation failures use code `2`; failed child processes use code `3`. +See [Troubleshooting](troubleshooting.md#stellarforge-dev-says-no-supported-script-exists). + ## `stellarforge test` Runs the supported project test workflow. @@ -163,6 +169,8 @@ StellarForge does not execute package script text directly. It delegates through - `2` — project/test configuration is unsupported or invalid; - `3` — the test subprocess fails. +See [Troubleshooting](troubleshooting.md#stellarforge-test-cannot-find-a-workflow). + ## `stellarforge deploy` Deploys a supported smart-contract Cargo project to **Stellar Testnet**. @@ -222,7 +230,7 @@ No shell interpolation is used. Signing material remains under Stellar CLI ident - `2` — configuration, project layout, network, or identity input is invalid; - `3` — Stellar CLI fails to start or returns a failure. -See [Configuration](configuration.md) and [Troubleshooting](troubleshooting.md). +See [Configuration](configuration.md) and the deployment sections of [Troubleshooting](troubleshooting.md#deployment-network-is-required): [network required](troubleshooting.md#deployment-network-is-required), [Mainnet or Futurenet rejection](troubleshooting.md#deployment-rejects-mainnet-or-futurenet), [identity rejected](troubleshooting.md#deployment-identity-is-rejected), and [Stellar CLI deployment fails](troubleshooting.md#stellar-cli-deployment-fails). ## Unsupported or planned behavior diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 00dfca7..0ded37b 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -63,3 +63,5 @@ The file must: - contain only supported schema fields. Validation failures are designed to be actionable without echoing raw file contents or sensitive values. + +If the file is rejected, see [Troubleshooting](troubleshooting.md#configuration-file-is-rejected). For deployment settings, see [Deployment network is required](troubleshooting.md#deployment-network-is-required) and [Deployment identity is rejected](troubleshooting.md#deployment-identity-is-rejected). diff --git a/docs/reference/exit-codes.md b/docs/reference/exit-codes.md index 4c4c6da..4ef71bc 100644 --- a/docs/reference/exit-codes.md +++ b/docs/reference/exit-codes.md @@ -15,6 +15,17 @@ StellarForge CLI uses predictable process exit codes so humans, scripts, and CI These values are part of the CLI contract and should not be changed casually. New stable categories require deliberate review and documentation. +## Troubleshooting by Exit Code + +| Exit code | Start here | +|---:|---| +| `2` | [Usage](troubleshooting.md#command-or-option-is-not-recognized), [`new`](troubleshooting.md#stellarforge-new-cannot-create-the-project), [project name](troubleshooting.md#project-name-is-rejected), [configuration](troubleshooting.md#configuration-file-is-rejected), [deployment network](troubleshooting.md#deployment-network-is-required), or [deployment identity](troubleshooting.md#deployment-identity-is-rejected) | +| `3` | [`dev`](troubleshooting.md#stellarforge-dev-says-no-supported-script-exists), [`test`](troubleshooting.md#stellarforge-test-cannot-find-a-workflow), or [Stellar CLI deployment](troubleshooting.md#stellar-cli-deployment-fails) | +| `5` | [`doctor`](troubleshooting.md#stellarforge-doctor-reports-failures) | +| `1` or `4` | [Unexpected or internal failure](troubleshooting.md#unexpected-or-internal-failure) | + +The full index is at [Troubleshooting](troubleshooting.md#find-your-problem). + ## Rendering Rules - user-facing failures are written to stderr unless a command intentionally renders a structured report to stdout; diff --git a/docs/reference/platform-support.md b/docs/reference/platform-support.md index be8950f..b36dd75 100644 --- a/docs/reference/platform-support.md +++ b/docs/reference/platform-support.md @@ -55,3 +55,7 @@ A green platform job validates StellarForge's code and tests on that OS; it does A failing Linux, macOS, or Windows matrix job blocks merge/release for changes that affect the supported CLI surface. A failure may be waived only through an explicit project decision that documents the affected platform, reason, user impact, and follow-up plan. Platform-specific failures must not be hidden with `continue-on-error` in the supported matrix. + +## Troubleshooting + +For installation and setup failures, see [Troubleshooting](troubleshooting.md#installation-or-setup-fails). Runtime and tool requirements are in [Installation](../guides/installation.md#contributor-prerequisites), and exit codes are defined in [Exit Codes](exit-codes.md). diff --git a/docs/reference/template-generation.md b/docs/reference/template-generation.md index 018637e..62d4bd8 100644 --- a/docs/reference/template-generation.md +++ b/docs/reference/template-generation.md @@ -17,3 +17,5 @@ StellarForge generates projects from project-controlled template directories onl If validation, copying, or substitution fails before promotion, the staging directory is removed and the final destination is left untouched. Existing non-empty destinations are never overwritten. An existing empty destination may be used, and if promotion fails after that empty directory is removed, StellarForge recreates the empty destination before returning the error. This model deliberately favors conservative failure over partial generation or implicit overwrite behavior. + +If generation is refused, see [Troubleshooting](troubleshooting.md#stellarforge-new-cannot-create-the-project) and [Project name is rejected](troubleshooting.md#project-name-is-rejected). diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 3905316..d64657d 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -2,7 +2,53 @@ This page covers common failures in the implemented StellarForge CLI MVP. -For numeric failure categories, see [Exit Codes](exit-codes.md). +## Find your problem + +| Failure category | Typical symptom | Go to | +| --- | --- | --- | +| Install and setup | `stellarforge` is not found, Node.js or npm is unsupported, or `npm ci` or the build fails | [Installation or setup fails](#installation-or-setup-fails) | +| Usage | Unknown command or option, or a missing argument | [Command or option is not recognized](#command-or-option-is-not-recognized) | +| Project creation | `stellarforge new` refuses the name, template, or destination | [`stellarforge new` cannot create the project](#stellarforge-new-cannot-create-the-project) and [Project name is rejected](#project-name-is-rejected) | +| Environment | `stellarforge doctor` reports a failed diagnostic | [`stellarforge doctor` reports failures](#stellarforge-doctor-reports-failures) | +| Local development | No supported script, or the npm process fails | [`stellarforge dev` says no supported script exists](#stellarforge-dev-says-no-supported-script-exists) | +| Testing | No supported test workflow, or the project test fails | [`stellarforge test` cannot find a workflow](#stellarforge-test-cannot-find-a-workflow) | +| Configuration | `stellarforge.config.json` is rejected | [Configuration file is rejected](#configuration-file-is-rejected) | +| Deployment | Network or identity is missing or rejected, Mainnet or Futurenet is refused, or Stellar CLI fails | [Deployment network is required](#deployment-network-is-required), [Deployment rejects Mainnet or Futurenet](#deployment-rejects-mainnet-or-futurenet), [Deployment identity is rejected](#deployment-identity-is-rejected), and [Stellar CLI deployment fails](#stellar-cli-deployment-fails) | +| Unexpected failure | A generic error message with exit code `1` or `4` | [Unexpected or internal failure](#unexpected-or-internal-failure) | +| Sensitive data | A secret appeared in output or in a report | [Sensitive information appeared in a failure](#sensitive-information-appeared-in-a-failure) | + +### Look up by exit code + +| Exit code | Where to look | +| ---: | --- | +| `2` | Usage and validation failures: [usage](#command-or-option-is-not-recognized), [`new`](#stellarforge-new-cannot-create-the-project), [project name](#project-name-is-rejected), [`dev`](#stellarforge-dev-says-no-supported-script-exists), [`test`](#stellarforge-test-cannot-find-a-workflow), [configuration](#configuration-file-is-rejected), [deployment network](#deployment-network-is-required), and [deployment identity](#deployment-identity-is-rejected) | +| `3` | A subprocess failed: [`dev`](#stellarforge-dev-says-no-supported-script-exists), [`test`](#stellarforge-test-cannot-find-a-workflow), or [Stellar CLI deployment](#stellar-cli-deployment-fails) | +| `5` | [`stellarforge doctor` reports failures](#stellarforge-doctor-reports-failures) | +| `1` or `4` | [Unexpected or internal failure](#unexpected-or-internal-failure) | + +Exit code meanings are defined in [Exit Codes](exit-codes.md). Related references: [Installation](../guides/installation.md), [Quick Start](../guides/quick-start.md), [Command Reference](commands.md), [Configuration](configuration.md), and [Platform Support](platform-support.md). + +## Installation or setup fails + +A supported public npm release does not exist yet, so `stellarforge: command not found` is expected until you have linked a source checkout. See [Installation](../guides/installation.md) for the current release state, and do not install the `0.0.0` bootstrap placeholder as an end-user release. + +From a source checkout, build and run the CLI directly: + +```bash +npm ci +npm run build +node dist/cli.js --help +``` + +To put the `stellarforge` command on your path from the checkout, follow [Local CLI invocation](../guides/installation.md#local-cli-invocation). + +If `npm ci`, the build, or the tests fail, check: + +- Node.js is `>=22.13.0 <25` and npm is `>=10.9.0` (`node --version` and `npm --version`); +- you used `npm ci` and not another package manager, because the repository commits `package-lock.json`; +- the full validation set from [Contributor setup](../guides/installation.md#contributor-setup) passes: `npm run typecheck`, `npm run lint`, `npm run format:check`, `npm test`, and `npm run build`. + +Operating-system differences, including Windows executable handling, are described in [Platform Support](platform-support.md#platform-differences). Once the CLI runs, `stellarforge doctor` checks the rest of the local toolchain. ## Command or option is not recognized @@ -21,12 +67,24 @@ Unknown commands/options and missing required positional arguments are usage fai Check: -- the project name is present and valid; +- the project name is present and valid (see [Project name is rejected](#project-name-is-rejected)); - the requested template is one of `basic-app`, `full-stack`, `smart-contract`, or `api-service`; - the destination is within the allowed working path; -- the target directory is not an existing non-empty directory. +- the target is not an existing file, and is not an existing non-empty directory. A directory holding only hidden files such as `.gitkeep` counts as non-empty, while an existing empty directory is allowed. -StellarForge intentionally refuses unsafe paths, unsupported templates, symlinked template entries, and implicit overwrite behavior. +StellarForge intentionally refuses unsafe paths, unsupported templates, symlinked template entries, and implicit overwrite behavior. These are validation failures and exit with code `2`. See [Template Generation Safety](template-generation.md) and the [`new` command reference](commands.md#stellarforge-new-project-name). + +## Project name is rejected + +A project name must be a single new directory name. Leading and trailing whitespace is trimmed first. The name is then rejected when it: + +- is empty, `.`, or `..`; +- contains a path separator or is an absolute path; +- contains characters other than letters, numbers, dots, underscores, and hyphens (spaces included), or does not start with a letter or number; +- ends with a dot; +- matches a reserved Windows device name: `CON`, `PRN`, `AUX`, `NUL`, `COM1` to `COM9`, or `LPT1` to `LPT9`. + +Reserved names are rejected case-insensitively, with or without an extension such as `con.txt`, and the same names are refused on Linux, macOS, and Windows. Similar names such as `console`, `com10`, or `demo.con` are accepted. Choose a different name and run `stellarforge new` again. ## `stellarforge doctor` reports failures @@ -36,6 +94,8 @@ The current diagnostics cover the Node.js/npm runtime and local tools used by su A diagnostic failure exits with code `5`. A warning may indicate a usable but incomplete environment and does not by itself fail the command. +Runtime and tool requirements are listed in [Contributor prerequisites](../guides/installation.md#contributor-prerequisites) and [External tools](platform-support.md#external-tools). + ## `stellarforge dev` says no supported script exists The project must contain a valid `package.json` with a `scripts` object and at least one of: @@ -50,6 +110,8 @@ Selection follows that order. If the selected npm process starts and then fails, StellarForge returns subprocess exit category `3`. +See the [`dev` command reference](commands.md#stellarforge-dev). + ## `stellarforge test` cannot find a workflow A supported test project must have either: @@ -61,6 +123,8 @@ Node.js projects are run through `npm run test`; Cargo projects are run through A failing project test is preserved as a subprocess failure instead of being converted to success. +See the [`test` command reference](commands.md#stellarforge-test). + ## Configuration file is rejected The file must be named exactly: @@ -145,9 +209,23 @@ Then verify that: A failed external process returns exit category `3`. +See the [`deploy` command reference](commands.md#stellarforge-deploy). + +## Unexpected or internal failure + +Exit code `1` (unexpected failure) and exit code `4` (internal failure) intentionally print a generic message and do not expose the original error message or stack trace. See the [rendering rules](exit-codes.md#rendering-rules). + +First confirm the basics: + +- `stellarforge --version` prints a version; +- Node.js and npm meet the supported range (see [Installation or setup fails](#installation-or-setup-fails)); +- the failure still happens in a freshly generated project. + +If it persists, collect the non-sensitive details listed in [Still blocked?](#still-blocked). If you believe the failure exposes a vulnerability, follow [SECURITY.md](../../SECURITY.md) instead of opening a public issue. + ## Sensitive information appeared in a failure -StellarForge errors are designed not to render raw secrets. Do not paste private keys, seed phrases, access tokens, or complete credential-bearing environment values into public issues. +StellarForge errors are designed not to render raw secrets. Do not paste private keys, seed phrases, wallet seed material, access tokens, full environment dumps, or complete credential-bearing environment values into public issues. If you believe a secret disclosure is caused by a vulnerability, follow [SECURITY.md](../../SECURITY.md) instead of opening a public issue. @@ -163,4 +241,4 @@ Collect only non-sensitive information: - the safe error message; - minimal project layout information. -Do not include secrets, full environment dumps, wallet seed material, or private signing keys. +Never include anything described in [Sensitive information appeared in a failure](#sensitive-information-appeared-in-a-failure), and never include private signing keys. diff --git a/tests/generator-destination-collision.test.ts b/tests/generator-destination-collision.test.ts new file mode 100644 index 0000000..a741210 --- /dev/null +++ b/tests/generator-destination-collision.test.ts @@ -0,0 +1,449 @@ +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { join, relative } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ValidationCliError } from '../src/errors/errors.js'; +import { orchestrateProjectGeneration } from '../src/generator/orchestrator.js'; +import { generateProjectFromTemplate } from '../src/generator/template-generation.js'; +import type { + GenerationContext, + ProjectGeneratorInput, +} from '../src/generator/types.js'; +import { + planProjectDestination, + validateProjectInput, +} from '../src/generator/validation.js'; +import type { DestinationFileSystem } from '../src/generator/validation.js'; +import { withTempDirectory } from './helpers/index.js'; + +const NOT_EMPTY = 'Destination directory is not empty'; +const NO_OVERWRITE = 'will not overwrite existing files'; + +function plan(root: string, projectName = 'demo') { + return planProjectDestination( + validateProjectInput({ projectName, cwd: root }), + ); +} + +function snapshot(root: string): readonly string[] { + const lines: string[] = []; + + function visit(directory: string): void { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort( + (a, b) => a.name.localeCompare(b.name), + )) { + const path = join(directory, entry.name); + const label = relative(root, path).split('\\').join('/'); + + if (entry.isDirectory()) { + lines.push(`${label}/`); + visit(path); + } else { + lines.push(`${label}=${readFileSync(path, 'utf8')}`); + } + } + } + + visit(root); + return lines; +} + +function createTemplate(root: string): string { + const templateRoot = join(root, 'template'); + mkdirSync(templateRoot); + writeFileSync(join(templateRoot, 'README.md'), '# {{projectName}}\n'); + return templateRoot; +} + +function generationContext(root: string): GenerationContext { + return { + input: { projectName: 'demo', cwd: root, templateId: 'basic-app' }, + destination: { projectName: 'demo', destination: join(root, 'demo') }, + template: { templateId: 'basic-app' }, + }; +} + +function stagingLeftovers(root: string): readonly string[] { + return readdirSync(root).filter((name) => name.startsWith('.stellarforge-')); +} + +interface FakeFileSystem extends DestinationFileSystem { + readonly calls: string[]; +} + +function createFakeFileSystem(options: { + readonly exists: boolean; + readonly isDirectory: boolean; + readonly entries: readonly (readonly string[])[]; +}): FakeFileSystem { + const calls: string[] = []; + let entryReads = 0; + + return { + calls, + exists() { + calls.push('exists'); + return options.exists; + }, + isDirectory() { + calls.push('isDirectory'); + return options.isDirectory; + }, + entries() { + calls.push('entries'); + const index = Math.min(entryReads, options.entries.length - 1); + entryReads += 1; + return options.entries[index] ?? []; + }, + }; +} + +describe('destination collision: existing file', () => { + it('rejects a regular file at the destination and leaves it untouched', () => + withTempDirectory((root) => { + writeFileSync(join(root, 'demo'), 'original'); + const before = snapshot(root); + + expect(() => plan(root)).toThrow(ValidationCliError); + expect(() => plan(root)).toThrow('is not a directory'); + expect(snapshot(root)).toEqual(before); + })); + + it('includes the colliding destination path in the message', () => + withTempDirectory((root) => { + writeFileSync(join(root, 'demo'), 'original'); + + expect(() => plan(root)).toThrow(join(root, 'demo')); + })); + + it('does not collide with sibling files that merely share a prefix', () => + withTempDirectory((root) => { + writeFileSync(join(root, 'demo.txt'), 'sibling'); + writeFileSync(join(root, 'demo-app'), 'sibling'); + mkdirSync(join(root, 'other')); + + expect(plan(root).destination).toBe(join(root, 'demo')); + })); +}); + +describe('destination collision: empty and absent directories', () => { + it('preserves the current policy: an existing empty directory is allowed', () => + withTempDirectory((root) => { + mkdirSync(join(root, 'demo')); + + expect(plan(root)).toEqual({ + projectName: 'demo', + destination: join(root, 'demo'), + }); + })); + + it('does not write anything while planning an empty directory', () => + withTempDirectory((root) => { + mkdirSync(join(root, 'demo')); + const before = snapshot(root); + + plan(root); + + expect(snapshot(root)).toEqual(before); + expect(readdirSync(join(root, 'demo'))).toEqual([]); + })); + + it('does not create an absent destination while planning', () => + withTempDirectory((root) => { + plan(root); + + expect(existsSync(join(root, 'demo'))).toBe(false); + expect(readdirSync(root)).toEqual([]); + })); +}); + +describe('destination collision: non-empty directories', () => { + it.each(['existing.txt', 'package.json', 'README.md'])( + 'refuses a directory containing visible file %s', + (fileName) => + withTempDirectory((root) => { + const destination = join(root, 'demo'); + mkdirSync(destination); + writeFileSync(join(destination, fileName), 'keep me'); + const before = snapshot(root); + + expect(() => plan(root)).toThrow(NOT_EMPTY); + expect(() => plan(root)).toThrow(NO_OVERWRITE); + expect(snapshot(root)).toEqual(before); + }), + ); + + it.each(['.gitkeep', '.DS_Store', '.env', '.hidden'])( + 'refuses a directory containing only hidden file %s', + (fileName) => + withTempDirectory((root) => { + const destination = join(root, 'demo'); + mkdirSync(destination); + writeFileSync(join(destination, fileName), 'hidden content'); + const before = snapshot(root); + + expect(() => plan(root)).toThrow(NOT_EMPTY); + expect(snapshot(root)).toEqual(before); + }), + ); + + it('refuses a directory containing only a hidden directory', () => + withTempDirectory((root) => { + const destination = join(root, 'demo'); + mkdirSync(join(destination, '.git'), { recursive: true }); + + expect(() => plan(root)).toThrow(NOT_EMPTY); + expect(existsSync(join(destination, '.git'))).toBe(true); + })); + + it('refuses a directory containing only an empty subdirectory', () => + withTempDirectory((root) => { + const destination = join(root, 'demo'); + mkdirSync(join(destination, 'src'), { recursive: true }); + + expect(() => plan(root)).toThrow(NOT_EMPTY); + expect(readdirSync(destination)).toEqual(['src']); + })); + + it('refuses a directory whose only content is deeply nested', () => + withTempDirectory((root) => { + const nested = join(root, 'demo', 'a', 'b', 'c'); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(nested, 'deep.txt'), 'deep'); + const before = snapshot(root); + + expect(() => plan(root)).toThrow(NOT_EMPTY); + expect(snapshot(root)).toEqual(before); + })); + + it('refuses a directory with mixed visible, hidden, and nested content', () => + withTempDirectory((root) => { + const destination = join(root, 'demo'); + mkdirSync(join(destination, 'src'), { recursive: true }); + writeFileSync(join(destination, '.gitkeep'), ''); + writeFileSync(join(destination, 'src', 'index.ts'), 'export {};'); + writeFileSync(join(destination, 'notes.txt'), 'notes'); + const before = snapshot(root); + + expect(() => plan(root)).toThrow(NO_OVERWRITE); + expect(snapshot(root)).toEqual(before); + })); + + it('names the colliding directory in the refusal message', () => + withTempDirectory((root) => { + const destination = join(root, 'demo'); + mkdirSync(destination); + writeFileSync(join(destination, 'existing.txt'), 'keep me'); + + expect(() => plan(root)).toThrow(destination); + })); +}); + +describe('destination collision: no implicit overwrite or force behavior', () => { + it.each([{ force: true }, { overwrite: true }, { clean: true }])( + 'ignores unsupported override flag %j and still refuses a non-empty directory', + (flags) => + withTempDirectory((root) => { + const destination = join(root, 'demo'); + mkdirSync(destination); + writeFileSync(join(destination, 'existing.txt'), 'keep me'); + const input: ProjectGeneratorInput & typeof flags = { + projectName: 'demo', + cwd: root, + ...flags, + }; + + const validated = validateProjectInput(input); + + expect(Object.keys(validated).sort()).toEqual(['cwd', 'projectName']); + expect(() => planProjectDestination(validated)).toThrow(NO_OVERWRITE); + expect(readFileSync(join(destination, 'existing.txt'), 'utf8')).toBe( + 'keep me', + ); + }), + ); + + it('only reads from the file system while planning', () => { + const fileSystem = createFakeFileSystem({ + exists: true, + isDirectory: true, + entries: [[]], + }); + + planProjectDestination( + validateProjectInput({ projectName: 'demo', cwd: '/workspace' }), + fileSystem, + ); + + expect(Object.keys(fileSystem).sort()).toEqual([ + 'calls', + 'entries', + 'exists', + 'isDirectory', + ]); + expect(fileSystem.calls).toEqual(['exists', 'isDirectory', 'entries']); + }); +}); + +describe('destination collision: deterministic revalidation', () => { + it('re-reads the destination on every planning call instead of caching', () => { + const fileSystem = createFakeFileSystem({ + exists: true, + isDirectory: true, + entries: [[], ['late-arrival.txt']], + }); + const validated = validateProjectInput({ + projectName: 'demo', + cwd: '/workspace', + }); + + expect(planProjectDestination(validated, fileSystem).projectName).toBe( + 'demo', + ); + expect(() => planProjectDestination(validated, fileSystem)).toThrow( + NOT_EMPTY, + ); + }); + + it('does not enumerate a destination that is a file', () => { + const fileSystem = createFakeFileSystem({ + exists: true, + isDirectory: false, + entries: [['never-read']], + }); + + expect(() => + planProjectDestination( + validateProjectInput({ projectName: 'demo', cwd: '/workspace' }), + fileSystem, + ), + ).toThrow('is not a directory'); + expect(fileSystem.calls).toEqual(['exists', 'isDirectory']); + }); + + it('does not inspect directory state when the destination is absent', () => { + const fileSystem = createFakeFileSystem({ + exists: false, + isDirectory: false, + entries: [['never-read']], + }); + + planProjectDestination( + validateProjectInput({ projectName: 'demo', cwd: '/workspace' }), + fileSystem, + ); + + expect(fileSystem.calls).toEqual(['exists']); + }); + + it('generation refuses a directory that became non-empty after planning', () => + withTempDirectory((root) => { + const templateRoot = createTemplate(root); + mkdirSync(join(root, 'demo')); + plan(root); + writeFileSync(join(root, 'demo', 'late-arrival.txt'), 'keep me'); + + expect(() => + generateProjectFromTemplate(generationContext(root), { templateRoot }), + ).toThrow('must be empty before generation'); + expect(readFileSync(join(root, 'demo', 'late-arrival.txt'), 'utf8')).toBe( + 'keep me', + ); + expect(readdirSync(join(root, 'demo'))).toEqual(['late-arrival.txt']); + expect(stagingLeftovers(root)).toEqual([]); + })); + + it('generation refuses a destination that appeared with content after planning', () => + withTempDirectory((root) => { + const templateRoot = createTemplate(root); + plan(root); + mkdirSync(join(root, 'demo')); + writeFileSync(join(root, 'demo', '.hidden'), 'keep me'); + + expect(() => + generateProjectFromTemplate(generationContext(root), { templateRoot }), + ).toThrow('must be empty before generation'); + expect(readFileSync(join(root, 'demo', '.hidden'), 'utf8')).toBe( + 'keep me', + ); + expect(stagingLeftovers(root)).toEqual([]); + })); + + it('generation refuses a destination that became a file after planning', () => + withTempDirectory((root) => { + const templateRoot = createTemplate(root); + plan(root); + writeFileSync(join(root, 'demo'), 'now a file'); + + expect(() => + generateProjectFromTemplate(generationContext(root), { templateRoot }), + ).toThrow('not a safe empty directory'); + expect(readFileSync(join(root, 'demo'), 'utf8')).toBe('now a file'); + expect(stagingLeftovers(root)).toEqual([]); + })); + + it('generation refuses an empty destination that became a link after planning', () => + withTempDirectory((root) => { + const templateRoot = createTemplate(root); + const elsewhere = join(root, 'elsewhere'); + mkdirSync(elsewhere); + plan(root); + symlinkSync(elsewhere, join(root, 'demo'), 'junction'); + + expect(() => + generateProjectFromTemplate(generationContext(root), { templateRoot }), + ).toThrow('not a safe empty directory'); + expect(readdirSync(elsewhere)).toEqual([]); + expect(stagingLeftovers(root)).toEqual([]); + })); + + it('revalidates between orchestration stages when a collision appears after planning', () => + withTempDirectory((root) => { + const templateRoot = createTemplate(root); + const input: ProjectGeneratorInput = { + projectName: 'demo', + cwd: root, + templateId: 'basic-app', + }; + + expect(() => + orchestrateProjectGeneration(input, { + validate: validateProjectInput, + planDestination: (validated) => planProjectDestination(validated), + selectTemplate() { + writeFileSync(join(root, 'demo'), 'created after planning'); + return { templateId: 'basic-app' }; + }, + generate: (context) => + generateProjectFromTemplate(context, { templateRoot }), + }), + ).toThrow(ValidationCliError); + expect(readFileSync(join(root, 'demo'), 'utf8')).toBe( + 'created after planning', + ); + expect(stagingLeftovers(root)).toEqual([]); + })); + + it('allows generation into an empty directory that is still empty after planning', () => + withTempDirectory((root) => { + const templateRoot = createTemplate(root); + mkdirSync(join(root, 'demo')); + plan(root); + + const result = generateProjectFromTemplate(generationContext(root), { + templateRoot, + }); + + expect(result.destination).toBe(join(root, 'demo')); + expect(readFileSync(join(root, 'demo', 'README.md'), 'utf8')).toBe( + '# demo\n', + ); + expect(stagingLeftovers(root)).toEqual([]); + })); +}); diff --git a/tests/generator-reserved-names.test.ts b/tests/generator-reserved-names.test.ts new file mode 100644 index 0000000..370a933 --- /dev/null +++ b/tests/generator-reserved-names.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { ValidationCliError } from '../src/errors/errors.js'; +import { validateProjectInput } from '../src/generator/validation.js'; + +const RESERVED_MESSAGE = 'reserved Windows device name'; +const TRAILING_MESSAGE = 'must not end with a dot or space'; + +const DEVICE_NAMES = [ + 'CON', + 'PRN', + 'AUX', + 'NUL', + ...Array.from({ length: 9 }, (_, index) => `COM${index + 1}`), + ...Array.from({ length: 9 }, (_, index) => `LPT${index + 1}`), +] as const; + +function alternatingCase(value: string): string { + return [...value] + .map((character, index) => + index % 2 === 0 ? character.toLowerCase() : character.toUpperCase(), + ) + .join(''); +} + +function casings(name: string): readonly string[] { + return [name, name.toLowerCase(), alternatingCase(name)]; +} + +function validate(projectName: string): string { + return validateProjectInput({ projectName, cwd: '/workspace' }).projectName; +} + +function expectRejected(projectName: string, message: RegExp | string): void { + expect(() => validate(projectName)).toThrow(ValidationCliError); + expect(() => validate(projectName)).toThrow(message); +} + +const BARE_AND_EXTENSION_VARIANTS = DEVICE_NAMES.flatMap((name) => + casings(name).flatMap((cased) => [ + cased, + `${cased}.txt`, + `${cased}.tar.gz`, + `${cased}.a.b.c`, + ]), +); + +const TRAILING_DOT_VARIANTS = DEVICE_NAMES.flatMap((name) => + casings(name).flatMap((cased) => [`${cased}.`, `${cased}.txt.`]), +); + +const TRAILING_SPACE_VARIANTS = DEVICE_NAMES.flatMap((name) => + casings(name).flatMap((cased) => [`${cased} `, `${cased} `, `${cased}\t`]), +); + +const VALID_SIMILAR_NAMES = [ + 'console', + 'conn', + 'con-app', + 'con_app', + 'con1', + 'my-con', + 'prnt', + 'printer', + 'auxiliary', + 'aux2', + 'nullable', + 'nul2', + 'com', + 'comm1', + 'com10', + 'com1x', + 'communication', + 'lpt', + 'lpt10', + 'lpt1x', + 'lptx', + 'demo.con', + 'demo.com1', + 'demo-nul', +] as const; + +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + +function stubPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); +} + +afterEach(() => { + if (originalPlatform !== undefined) { + Object.defineProperty(process, 'platform', originalPlatform); + } +}); + +describe('Windows reserved device name matrix', () => { + it('covers every reserved device name family', () => { + expect(DEVICE_NAMES).toHaveLength(22); + expect(new Set(DEVICE_NAMES).size).toBe(DEVICE_NAMES.length); + }); + + it.each(BARE_AND_EXTENSION_VARIANTS)( + 'rejects reserved name or reserved name with extension %j', + (projectName) => { + expectRejected(projectName, RESERVED_MESSAGE); + }, + ); + + it.each(TRAILING_DOT_VARIANTS)( + 'rejects reserved name with trailing dot %j', + (projectName) => { + expectRejected( + projectName, + new RegExp(`${RESERVED_MESSAGE}|${TRAILING_MESSAGE}`), + ); + }, + ); + + it.each(TRAILING_SPACE_VARIANTS)( + 'rejects reserved name with trailing whitespace %j', + (projectName) => { + expectRejected(projectName, RESERVED_MESSAGE); + }, + ); + + it.each(['CON ', 'nul. ', 'Com1 .', 'LPT9.txt .'])( + 'rejects mixed trailing dot and space variant %j', + (projectName) => { + expect(() => validate(projectName)).toThrow(ValidationCliError); + }, + ); + + it.each(['CON .txt', 'AUX .log', 'com1 .x'])( + 'rejects reserved name with an internal space %j', + (projectName) => { + expect(() => validate(projectName)).toThrow(ValidationCliError); + }, + ); + + it.each(VALID_SIMILAR_NAMES)( + 'keeps similar but valid name %j accepted', + (projectName) => { + expect(validate(projectName)).toBe(projectName); + }, + ); + + it.each(VALID_SIMILAR_NAMES.map((name) => name.toUpperCase()))( + 'keeps upper-case similar but valid name %j accepted', + (projectName) => { + expect(validate(projectName)).toBe(projectName); + }, + ); + + it.each(['win32', 'linux', 'darwin'] as const)( + 'asserts the same behavior when the host platform is %s', + (platform) => { + stubPlatform(platform); + + for (const projectName of [ + 'CON', + 'nul.txt', + 'Com9', + 'lpt1.log', + 'AUX.', + 'prn ', + ]) { + expect(() => validate(projectName)).toThrow(ValidationCliError); + } + + for (const projectName of ['console', 'com10', 'lpt10', 'demo-app']) { + expect(validate(projectName)).toBe(projectName); + } + }, + ); +}); diff --git a/tests/template-registry-metadata.test.ts b/tests/template-registry-metadata.test.ts new file mode 100644 index 0000000..3f72034 --- /dev/null +++ b/tests/template-registry-metadata.test.ts @@ -0,0 +1,247 @@ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { isAbsolute, join, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ValidationCliError } from '../src/errors/errors.js'; +import * as registry from '../src/generator/template-registry.js'; +import { + getTemplate, + listTemplates, + selectTemplate, + TEMPLATE_IDS, +} from '../src/generator/template-registry.js'; +import { resolveBundledTemplateRoot } from '../src/generator/template-sources.js'; +import { repositoryRoot } from './helpers/index.js'; + +const EXPECTED_IDS = [ + 'basic-app', + 'full-stack', + 'smart-contract', + 'api-service', +] as const; + +const SUPPORTED_MESSAGE = + 'Supported templates: basic-app, full-stack, smart-contract, api-service.'; + +const UNSUPPORTED_IDENTIFIERS = [ + '', + ' ', + 'unknown', + 'basic-app ', + ' basic-app', + 'Basic-App', + 'BASIC-APP', + 'basic_app', + 'basic-app/', + './basic-app', + '../basic-app', + '../outside', + 'templates/basic-app', + '/tmp/template', + 'C:\\templates\\basic-app', + 'C:/templates/basic-app', + '\\\\server\\share\\template', + '~/template', + 'file:///tmp/template', + 'https://example.com/template.git', + 'http://example.com/template.tgz', + 'git+https://example.com/template.git', + 'git@example.com:owner/template.git', + 'github:owner/template', + 'owner/template', + 'npm:stellarforge-template', + '@scope/stellarforge-template', + 'stellarforge-plugin-custom', + 'plugin:custom', + 'constructor', + 'toString', + '__proto__', + 'hasOwnProperty', +] as const; + +describe('template registry: controlled template set', () => { + it('exposes exactly the four controlled template IDs in order', () => { + expect(TEMPLATE_IDS).toEqual(EXPECTED_IDS); + expect(TEMPLATE_IDS).toHaveLength(4); + }); + + it('lists exactly the controlled template IDs in the same order', () => { + expect(listTemplates().map((template) => template.id)).toEqual( + EXPECTED_IDS, + ); + }); + + it('keeps template IDs unique', () => { + const ids = listTemplates().map((template) => template.id); + + expect(new Set(ids).size).toBe(ids.length); + expect(new Set(TEMPLATE_IDS).size).toBe(TEMPLATE_IDS.length); + }); + + it('keeps template display names unique', () => { + const names = listTemplates().map((template) => template.name); + + expect(new Set(names).size).toBe(names.length); + }); + + it('keeps every template ID a lowercase hyphenated slug', () => { + for (const id of TEMPLATE_IDS) { + expect(id).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); + } + }); + + it('returns the same registry entry from getTemplate as from the list', () => { + for (const template of listTemplates()) { + expect(getTemplate(template.id)).toBe(template); + } + }); +}); + +describe('template registry: metadata completeness', () => { + it.each(EXPECTED_IDS)('has complete metadata for %s', (id) => { + const template = getTemplate(id); + + expect(template.id).toBe(id); + expect(template.name.trim()).toBe(template.name); + expect(template.name.length).toBeGreaterThan(0); + expect(template.description.trim()).toBe(template.description); + expect(template.description.length).toBeGreaterThan(0); + }); + + it('carries only id, name, and description with no path, URL, or plugin fields', () => { + for (const template of listTemplates()) { + expect(Object.keys(template).sort()).toEqual([ + 'description', + 'id', + 'name', + ]); + } + }); + + it('does not export any registration, plugin, or remote-loading entry point', () => { + const unexpected = Object.keys(registry).filter((name) => + /register|plugin|remote|fetch|load|install/i.test(name), + ); + + expect(unexpected).toEqual([]); + }); +}); + +describe('template registry: bundled source mapping', () => { + it.each(EXPECTED_IDS)( + 'maps %s to an existing bundled template directory', + (id) => { + const root = resolveBundledTemplateRoot(id); + + expect(isAbsolute(root)).toBe(true); + expect(resolve(root)).toBe( + resolve(join(repositoryRoot, 'templates', id)), + ); + expect(existsSync(root)).toBe(true); + expect(statSync(root).isDirectory()).toBe(true); + expect(readdirSync(root).length).toBeGreaterThan(0); + }, + ); + + it('maps every template ID to a distinct bundled directory', () => { + const roots = EXPECTED_IDS.map((id) => resolveBundledTemplateRoot(id)); + + expect(new Set(roots).size).toBe(roots.length); + }); + + it('bundles no template directory that is missing from the registry', () => { + const bundled = readdirSync(join(repositoryRoot, 'templates')).sort(); + + expect(bundled).toEqual([...EXPECTED_IDS].sort()); + }); + + it.each(['unknown', 'https://example.com/template.git', '../outside'])( + 'refuses to map unregistered identifier %j to a bundled directory', + (identifier) => { + expect(() => resolveBundledTemplateRoot(identifier)).toThrow( + ValidationCliError, + ); + }, + ); +}); + +describe('template registry: explicit default template', () => { + it('selects basic-app when no template is requested', () => { + expect(selectTemplate({ projectName: 'demo', cwd: '/workspace' })).toEqual({ + templateId: 'basic-app', + }); + }); + + it('keeps the default equal to an explicit basic-app request', () => { + const implicit = selectTemplate({ projectName: 'demo', cwd: '/workspace' }); + const explicit = selectTemplate({ + projectName: 'demo', + cwd: '/workspace', + templateId: 'basic-app', + }); + + expect(implicit).toEqual(explicit); + }); + + it('keeps the default a registered template and the first listed entry', () => { + const { templateId } = selectTemplate({ + projectName: 'demo', + cwd: '/workspace', + }); + + expect(TEMPLATE_IDS).toContain(templateId); + expect(listTemplates()[0]?.id).toBe(templateId); + }); + + it('keeps the default independent of project name and working directory', () => { + for (const input of [ + { projectName: 'a', cwd: '/one' }, + { projectName: 'smart-contract', cwd: '/two' }, + { projectName: 'api-service', cwd: 'relative/path' }, + ]) { + expect(selectTemplate(input)).toEqual({ templateId: 'basic-app' }); + } + }); + + it('keeps the default stable across repeated calls', () => { + const results = Array.from({ length: 5 }, () => + selectTemplate({ projectName: 'demo', cwd: '/workspace' }), + ); + + expect(new Set(results.map((result) => result.templateId))).toEqual( + new Set(['basic-app']), + ); + }); + + it.each(EXPECTED_IDS)('selects %s when explicitly requested', (id) => { + expect( + selectTemplate({ + projectName: 'demo', + cwd: '/workspace', + templateId: id, + }), + ).toEqual({ templateId: id }); + }); +}); + +describe('template registry: unsupported identifiers', () => { + it.each(UNSUPPORTED_IDENTIFIERS)( + 'rejects %j from getTemplate and lists the supported templates', + (identifier) => { + expect(() => getTemplate(identifier)).toThrow(ValidationCliError); + expect(() => getTemplate(identifier)).toThrow(SUPPORTED_MESSAGE); + }, + ); + + it.each(UNSUPPORTED_IDENTIFIERS)( + 'rejects %j from selectTemplate without falling back to the default', + (identifier) => { + expect(() => + selectTemplate({ + projectName: 'demo', + cwd: '/workspace', + templateId: identifier, + }), + ).toThrow(ValidationCliError); + }, + ); +});