diff --git a/.changeset/init-first-success.md b/.changeset/init-first-success.md new file mode 100644 index 0000000..dc8afc1 --- /dev/null +++ b/.changeset/init-first-success.md @@ -0,0 +1,9 @@ +--- +"notcms": minor +--- + +Complete CLI initialization through the first schema pull. `notcms init` now +uses existing credentials or browser login, ensures `notcms` is a direct and +resolvable project dependency with the safely detected package manager, writes +the generated schema, and prints a safe runnable first-query example while +preserving the standalone `login`, `pull`, and `pull --check` commands. diff --git a/README.md b/README.md index 2ccdc18..c4d1ec3 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ NotCMS makes it easy to create a CMS, from Notion. It provides a type-safe TypeS - 🎯 **Simple API**: Clean and intuitive API for fetching content - 📝 **Notion as Backend**: Use Notion's user-friendly editor for content creation - 🔄 **Framework Agnostic**: Works with any JavaScript framework (Next.js, React, Vue, etc.) -- 🛠️ **CLI Tools**: Includes notcms-kit for easy project setup and schema management +- 🛠️ **CLI Tools**: Includes the NotCMS CLI for project setup, browser login, and schema management ## Getting Started @@ -46,42 +46,37 @@ npm install notcms ### Usage -#### 0. Set Up Environment Variables - -Create a `.env` file in your project root: - -```env -NOTCMS_SECRET_KEY=your_secret_key -NOTCMS_WORKSPACE_ID=your_workspace_id -``` - -You can get these values from the NotCMS Dashboard. - #### 1. Initialize a Project -NotCMS Kit provides command-line tools to streamline your workflow. You can use it directly with npx: +Run the CLI directly with npx: ```bash -npx notcms-kit init +npx notcms init ``` -This will create a `notcms.config.json` file in your project root. +This creates `notcms.config.json`. If credentials are missing, it opens browser +login and saves `NOTCMS_SECRET_KEY` and `NOTCMS_WORKSPACE_ID` to `.env.local`. +It ensures `notcms` is both a direct dependency and available to the project, +offering either the detected package manager's install command or an add +command without replacing an existing dependency spec. It then pulls the +schema and prints a runnable first query example. -#### 2. Define Your Schema +#### 2. Refresh Your Schema -The easiest way to define your schema is to use NotCMS Kit: +Run the standalone pull command whenever your Notion database schema changes: ```bash -npx notcms-kit pull +npx notcms pull ``` -This will automatically fetch your database schema from Notion and generate a TypeScript schema file. +This fetches your database schema from NotCMS and regenerates the configured TypeScript file. For reference, the generated schema will look something like this: ```ts // src/notcms/schema.ts -import { Client, Schema } from "notcms"; +import { Client } from "notcms"; +import type { Schema } from "notcms"; export const schema = { blog: { @@ -91,7 +86,7 @@ export const schema = { description: "rich_text", published: "checkbox", thumbnails: "files", - // Run notcms-kit pull again when properties are updated + // Run notcms pull again when properties are updated }, }, } satisfies Schema; @@ -142,7 +137,7 @@ If you encounter schema-related errors: 1. Make sure your Notion database IDs are correct 2. Verify that the property types match what's in your Notion database -3. Try running `npx notcms-kit pull` to regenerate your schema +3. Try running `npx notcms pull` to regenerate your schema ## Contributing diff --git a/docs/en/cli-commands/init.mdx b/docs/en/cli-commands/init.mdx index df98e7f..0ca0cae 100644 --- a/docs/en/cli-commands/init.mdx +++ b/docs/en/cli-commands/init.mdx @@ -1,58 +1,109 @@ --- title: init -description: Initialize a new NotCMS project +description: Initialize NotCMS through browser login and the first schema pull --- ## Overview -The `init` command sets up NotCMS in your project with interactive configuration. +The `init` command takes a project from configuration through its first schema +pull in one interactive flow. ```bash -npx notcms-kit init +npx notcms init ``` ## What it does -1. Creates `notcms.config.json` configuration file -2. Checks environment variables in `.env` +1. Prompts for the generated schema path +2. Creates `notcms.config.json` +3. Loads existing credentials from `.env`, `.env.local`, or `.dev.vars` +4. If credentials are missing, offers browser login and saves them to `.env.local` +5. Ensures `notcms` is both a direct project dependency and resolvable +6. Pulls the schema automatically +7. Prints a first query using the generated `nc` client + +If you decline browser login, the config file remains available and the command +shows how to run `notcms login` later. It does not attempt to pull without +credentials. ## Options | Option | Description | Default | -|--------|-------------|---------| -| `--env` | Environment file path | `[".env", ".env.local", ".dev.vars"]` | +| --- | --- | --- | +| `-e, --env ` | Comma-separated environment file paths | `.env,.env.local,.dev.vars` | + +## Interactive setup -## Interactive Setup +The command first asks where to save the generated schema: -The command will guide you through: +```text +? Enter the path to save the schema (src/notcms/schema.ts) +``` -### 1. Configuration +When credentials are missing, it asks before starting browser login: +```text +? No NotCMS credentials found. Log in via browser to set them up now? (Y/n) ``` -? Schema file path: ./src/notcms/schema.ts + +The browser flow displays a device code, lets you select a workspace, and +returns credentials to the CLI. The same credentials are used immediately for +the automatic schema pull. + +`init` considers setup complete only when `notcms` is declared directly in +`dependencies` or `devDependencies` and can be resolved from the target project. +If it is declared but not installed, `init` offers the package manager's plain +install command so local, workspace, and pinned dependency specs are preserved: + +```text +pnpm install +npm install +yarn install +bun install ``` +If it is not declared directly, `init` offers the corresponding add command: + +```text +pnpm add notcms +npm install notcms +yarn add notcms +bun add notcms +``` + +It shows the exact command and asks before running it, with yes as the default. +On POSIX platforms the package manager is executed directly with shell mode +disabled. On Windows, fixed validated arguments are passed through `cmd.exe` to +invoke the package manager's `.cmd` shim; project-controlled text is never +interpolated into that command. If installation is declined, fails, or the +session is non-interactive, schema generation continues and the manual +installation command is printed as the final action. + +The package manager is selected from a valid `packageManager` field, the closest +lockfiles, or the invoking package manager. An invalid explicit field, or +conflicting closest lockfiles that the invoking package manager cannot +disambiguate, stops automatic installation. Schema generation still completes +and `init` explains how to resolve the package-manager configuration manually. + ## Examples ### Basic initialization ```bash -npx notcms-kit init +npx notcms init ``` -### Specify environment file path - -Use this if you already have `.env` configured: +### Load credentials from a custom env file ```bash -npx notcms-kit init --env .env.development +npx notcms --env .env.development init ``` -## Troubleshooting +## Next steps -## Next Steps +Import the `nc` client from the generated schema file and run the query printed +by the command. When the Notion database structure changes, refresh it with: -After initialization, you can: - -1. Start using the NotCMS client in your code -2. Run `npx notcms-kit pull` to update your schema \ No newline at end of file +```bash +npx notcms pull +``` diff --git a/docs/en/cli-commands/pull.mdx b/docs/en/cli-commands/pull.mdx index f1603d5..b2f1658 100644 --- a/docs/en/cli-commands/pull.mdx +++ b/docs/en/cli-commands/pull.mdx @@ -1,51 +1,66 @@ --- title: pull -description: Generate TypeScript schema from Notion databases +description: Generate a TypeScript schema from registered Notion databases --- ## Overview -The `pull` command generates or updates your TypeScript schema from Notion databases. +The `pull` command generates or updates the configured TypeScript schema from +the databases registered in your NotCMS workspace. ```bash -npx notcms-kit pull +npx notcms pull ``` +`init` already performs the first pull. Run this standalone command after a +database or its properties change. + ## What it does -1. Fetches database structure and properties -2. Generates TypeScript types -3. Updates your schema file +1. Loads `NOTCMS_SECRET_KEY` and `NOTCMS_WORKSPACE_ID` +2. Fetches the current workspace schema from NotCMS +3. Generates the configured schema module and its `nc` client +4. Prints a query for the first database ## Options | Option | Description | Default | -|--------|-------------|---------| -| `--env` | Environment file path | `[".env", ".env.local", ".dev.vars"]` | +| --- | --- | --- | +| `-e, --env ` | Comma-separated environment file paths | `.env,.env.local,.dev.vars` | +| `--check` | Check whether the schema is current without writing | `false` | + +`--check` exits with code 1 when the generated file is missing or out of date, +which makes it suitable for CI/CD. ## Examples -### Basic pull +### Update the schema + +```bash +npx notcms pull +``` + +### Load credentials from a custom env file ```bash -npx notcms-kit pull +npx notcms --env .env.development pull ``` -### Specify environment file path +### Check without writing ```bash -npx notcms-kit pull --env .env.development +npx notcms pull --check ``` -## Generated Schema +## Generated schema -The command generates a fully typed schema: +The command generates both the typed schema and a ready-to-use client: ```typescript -import { Schema } from 'notcms'; +import { Client } from "notcms"; +import type { Schema } from "notcms"; export const schema = { - // example blog: { id: "abc123...", properties: { @@ -54,20 +69,20 @@ export const schema = { published: "checkbox", publishDate: "date", author: "relation", - tags: "multi_select" - } - } + tags: "multi_select", + }, + }, } satisfies Schema; -``` -## Schema Updates +export const nc = new Client({ schema }); +``` -When you update Notion databases, the pull command will update your schema. +You can add a package script for later updates: ```json { "scripts": { - "schema:pull": "notcms-kit pull", + "schema:pull": "notcms pull" } } -``` \ No newline at end of file +``` diff --git a/docs/en/examples/nextjs.mdx b/docs/en/examples/nextjs.mdx index 00042bf..f42626a 100644 --- a/docs/en/examples/nextjs.mdx +++ b/docs/en/examples/nextjs.mdx @@ -20,13 +20,12 @@ cd my-blog ```bash npm install notcms -npm install -D notcms-kit ``` ### 3. Initialize NotCMS ```bash -npx notcms-kit init +npx notcms init ``` ### 4. Configure Next.js @@ -430,4 +429,4 @@ NOTCMS_WORKSPACE_ID=your-workspace-id Explore usage of NotCMS - \ No newline at end of file + diff --git a/docs/en/quickstart.mdx b/docs/en/quickstart.mdx index 5e8ea5e..3a3f0af 100644 --- a/docs/en/quickstart.mdx +++ b/docs/en/quickstart.mdx @@ -38,37 +38,27 @@ bun add notcms Use the NotCMS CLI to set up your project: ```bash -npx notcms-kit init +npx notcms init ``` This command will: -1. Check environment variables -2. Create a `notcms.config.json` configuration file +1. Create a `notcms.config.json` configuration file +2. Open browser login when credentials are missing and save them to `.env.local` +3. Pull a type-safe schema to `src/notcms/schema.ts` +4. Print a first query example -## Step 3: Configure Environment Variables - -Add your NotCMS credentials to your `.env` file: - -```env -NOTCMS_SECRET_KEY=your-secret-key -NOTCMS_WORKSPACE_ID=your-workspace-id -``` - - - Get your credentials from the [NotCMS Dashboard](https://dash.notcms.com) - - -## Step 4: Pull Your Schema - -Generate TypeScript types from your Notion databases: +Run the standalone pull command later whenever your Notion database schema changes: ```bash -npx notcms-kit pull +npx notcms pull ``` -This creates a type-safe schema file at `src/notcms/schema.ts`: +The generated schema looks like this: ```typescript +import { Client } from "notcms"; +import type { Schema } from "notcms"; + export const schema = { blog: { id: "abc123...", @@ -80,17 +70,16 @@ export const schema = { } } } satisfies Schema; + +export const nc = new Client({ schema }); ``` -## Step 5: Start Using NotCMS +## Step 3: Start Using NotCMS -Create a client and start querying your content: +Import the generated client and start querying your content: ```typescript -import { Client } from 'notcms'; -import { schema } from './notcms/schema'; - -const nc = new Client({ schema }); +import { nc } from './notcms/schema'; // Fetch all published blog posts const [posts, error] = await nc.query.blog.list(); @@ -133,6 +122,6 @@ if (error) { - Run `npx notcms-kit pull` again to regenerate the schema if you've made changes to your Notion databases. + Run `npx notcms pull` again to regenerate the schema if you've made changes to your Notion databases. diff --git a/docs/en/usage/initializing-client.mdx b/docs/en/usage/initializing-client.mdx index 73b7f8a..ee7c9df 100644 --- a/docs/en/usage/initializing-client.mdx +++ b/docs/en/usage/initializing-client.mdx @@ -22,7 +22,7 @@ const nc = new Client({ Before initializing the client, ensure you have: -1. **Generated schema** - Run `npx notcms-kit init` to generate for the first time or `npx notcms-kit pull` to update your schema +1. **Generated schema** - Run `npx notcms init` to generate for the first time or `npx notcms pull` to update your schema 2. **Environment variables** - Set up your API credentials 3. **Installed NotCMS** - Add `notcms` to your project dependencies @@ -133,14 +133,14 @@ export const nc = new Client({ - - Run `npx notcms-kit pull` to generate schema + - Run `npx notcms pull` to generate schema - Check import path is correct - Ensure schema file exports correctly - Update to latest NotCMS version - - Regenerate schema with `npx notcms-kit pull` + - Regenerate schema with `npx notcms pull` - Check TypeScript version compatibility @@ -156,4 +156,4 @@ Once your client is initialized, you can: Fetch individual items by ID - \ No newline at end of file + diff --git a/docs/ja/cli-commands/init.mdx b/docs/ja/cli-commands/init.mdx index dd6a408..b120b0c 100644 --- a/docs/ja/cli-commands/init.mdx +++ b/docs/ja/cli-commands/init.mdx @@ -1,58 +1,106 @@ --- title: init -description: 新しいNotCMSプロジェクトの初期化 +description: ブラウザログインから最初のスキーマ取得までNotCMSを初期化 --- ## 概要 -`init`コマンドは、対話型の設定でプロジェクトにNotCMSをセットアップします。 +`init`コマンドは、プロジェクトの設定から最初のスキーマ取得までを +1つの対話フローで完了します。 ```bash -npx notcms-kit init +npx notcms init ``` ## 実行内容 -1. `notcms.config.json` 設定ファイルを作成 -2. `.env`内の環境変数をチェック +1. 生成するスキーマの保存先を確認 +2. `notcms.config.json`を作成 +3. `.env`、`.env.local`、`.dev.vars`から既存の認証情報を読み込み +4. 認証情報がなければブラウザログインを提案し、`.env.local`へ保存 +5. `notcms`が直接依存として宣言され、プロジェクトから解決可能かを確認 +6. スキーマを自動取得 +7. 生成された`nc`クライアントを使う最初のクエリを表示 + +ブラウザログインを拒否した場合も設定ファイルは残り、後から +`notcms login`を実行する方法を表示します。認証情報なしでpullは実行しません。 ## オプション | オプション | 説明 | デフォルト | -|--------|-------------|---------| -| `--env` | 環境ファイルのパス | `[".env", ".env.local", ".dev.vars"]` | +| --- | --- | --- | +| `-e, --env ` | カンマ区切りの環境ファイルパス | `.env,.env.local,.dev.vars` | ## 対話型セットアップ -コマンドが以下をガイドします: +最初に、生成するスキーマの保存先を確認します: + +```text +? Enter the path to save the schema (src/notcms/schema.ts) +``` -### 1. 設定 +認証情報がない場合は、ブラウザログインを開始する前に確認します: +```text +? No NotCMS credentials found. Log in via browser to set them up now? (Y/n) ``` -? Schema file path: ./src/notcms/schema.ts + +ブラウザにはデバイスコードとワークスペース選択が表示されます。認証後に +返された認証情報は、そのまま自動スキーマ取得に利用されます。 + +`init`は、`notcms`が`dependencies`または`devDependencies`に直接宣言され、 +対象プロジェクトから解決できる場合だけセットアップ済みと判断します。直接宣言済みでも +未インストールの場合は、ローカル・workspace・固定バージョンなど既存の依存指定を +維持するため、package managerの通常のinstallコマンドを提案します: + +```text +pnpm install +npm install +yarn install +bun install ``` +直接宣言されていない場合は、対応するaddコマンドを提案します: + +```text +pnpm add notcms +npm install notcms +yarn add notcms +bun add notcms +``` + +実行前に正確なコマンドを表示し、デフォルトyesで確認します。POSIX環境ではshell +modeを無効にしてpackage managerを直接実行します。Windowsではpackage managerの +`.cmd` shimを起動するため、固定された検証済み引数だけを`cmd.exe`へ渡し、 +プロジェクト由来の文字列をコマンドへ展開しません。拒否、インストール失敗、 +非対話セッションの場合もスキーマ生成を続け、最後に手動インストールコマンドを +表示します。 + +package managerは、有効な`packageManager`フィールド、最も近いlockfile、または +起動元package managerから選択します。明示されたフィールドが無効な場合や、最も近い +場所に競合するlockfileがあり起動元からも判定できない場合は、自動実行しません。 +スキーマ生成は完了させたうえで、package manager設定を手動で解決する方法を表示します。 + ## 例 ### 基本的な初期化 ```bash -npx notcms-kit init +npx notcms init ``` -### 環境ファイルパスの指定 - -すでに`.env`が設定されている場合に使用: +### 独自の環境ファイルから認証情報を読み込む ```bash -npx notcms-kit init --env .env.development +npx notcms --env .env.development init ``` -## トラブルシューティング - ## 次のステップ -初期化後、以下を実行できます: +生成されたスキーマファイルから`nc`クライアントをimportし、コマンドが表示した +クエリを実行します。Notionデータベースの構造を変更した後は、次のコマンドで +更新できます: -1. コード内でNotCMSクライアントの使用を開始 -2. `npx notcms-kit pull`を実行してスキーマを更新 \ No newline at end of file +```bash +npx notcms pull +``` diff --git a/docs/ja/cli-commands/pull.mdx b/docs/ja/cli-commands/pull.mdx index 821210f..8616c4e 100644 --- a/docs/ja/cli-commands/pull.mdx +++ b/docs/ja/cli-commands/pull.mdx @@ -1,51 +1,66 @@ --- title: pull -description: NotionデータベースからTypeScriptスキーマを生成 +description: 登録済みNotionデータベースからTypeScriptスキーマを生成 --- ## 概要 -`pull`コマンドは、NotionデータベースからTypeScriptスキーマを生成または更新します。 +`pull`コマンドは、NotCMSワークスペースへ登録されたデータベースから、設定済みの +TypeScriptスキーマを生成または更新します。 ```bash -npx notcms-kit pull +npx notcms pull ``` +最初のpullは`init`が自動で実行します。データベースまたはプロパティを変更した後に、 +この単独コマンドを実行してください。 + ## 実行内容 -1. データベースの構造とプロパティを取得 -2. TypeScript型を生成 -3. スキーマファイルを更新 +1. `NOTCMS_SECRET_KEY`と`NOTCMS_WORKSPACE_ID`を読み込み +2. NotCMSから現在のワークスペーススキーマを取得 +3. 設定されたスキーマmoduleと`nc`クライアントを生成 +4. 最初のデータベース用クエリを表示 ## オプション | オプション | 説明 | デフォルト | -|--------|-------------|---------| -| `--env` | 環境ファイルのパス | `[".env", ".env.local", ".dev.vars"]` | +| --- | --- | --- | +| `-e, --env ` | カンマ区切りの環境ファイルパス | `.env,.env.local,.dev.vars` | +| `--check` | ファイルを書き込まずスキーマが最新か確認 | `false` | + +`--check`は生成ファイルが存在しない、または古い場合に終了コード1を返すため、 +CI/CDで利用できます。 ## 例 -### 基本的なプル +### スキーマを更新 + +```bash +npx notcms pull +``` + +### 独自の環境ファイルから認証情報を読み込む ```bash -npx notcms-kit pull +npx notcms --env .env.development pull ``` -### 環境ファイルパスの指定 +### ファイルを書き込まず確認 ```bash -npx notcms-kit pull --env .env.development +npx notcms pull --check ``` ## 生成されるスキーマ -このコマンドは完全に型付けされたスキーマを生成します: +型付きスキーマと、すぐ利用できるクライアントの両方を生成します: ```typescript -import { Schema } from 'notcms'; +import { Client } from "notcms"; +import type { Schema } from "notcms"; export const schema = { - // 例 blog: { id: "abc123...", properties: { @@ -54,20 +69,20 @@ export const schema = { published: "checkbox", publishDate: "date", author: "relation", - tags: "multi_select" - } - } + tags: "multi_select", + }, + }, } satisfies Schema; -``` -## スキーマの更新 +export const nc = new Client({ schema }); +``` -Notionデータベースを更新すると、pullコマンドがスキーマを更新します。 +後から更新するためのpackage scriptも追加できます: ```json { "scripts": { - "schema:pull": "notcms-kit pull", + "schema:pull": "notcms pull" } } -``` \ No newline at end of file +``` diff --git a/docs/ja/examples/nextjs.mdx b/docs/ja/examples/nextjs.mdx index 8c7deb8..4053d33 100644 --- a/docs/ja/examples/nextjs.mdx +++ b/docs/ja/examples/nextjs.mdx @@ -20,13 +20,12 @@ cd my-blog ```bash npm install notcms -npm install -D notcms-kit ``` ### 3. NotCMSを初期化 ```bash -npx notcms-kit init +npx notcms init ``` ### 4. Next.jsを設定 @@ -430,4 +429,4 @@ NOTCMS_WORKSPACE_ID=your-workspace-id NotCMSの使い方を探る - \ No newline at end of file + diff --git a/docs/ja/quickstart.mdx b/docs/ja/quickstart.mdx index 0ac774d..a86e605 100644 --- a/docs/ja/quickstart.mdx +++ b/docs/ja/quickstart.mdx @@ -38,37 +38,27 @@ bun add notcms NotCMS CLIを使用してプロジェクトをセットアップ: ```bash -npx notcms-kit init +npx notcms init ``` このコマンドは以下を実行します: -1. 環境変数を確認 -2. `notcms.config.json`設定ファイルを作成 +1. `notcms.config.json`設定ファイルを作成 +2. 認証情報がなければブラウザでログインし、`.env.local`へ保存 +3. タイプセーフなスキーマを`src/notcms/schema.ts`へ取得 +4. 最初のクエリ例を表示 -## ステップ3: 環境変数を設定 - -`.env`ファイルにNotCMS認証情報を追加: - -```env -NOTCMS_SECRET_KEY=your-secret-key -NOTCMS_WORKSPACE_ID=your-workspace-id -``` - - - 認証情報は[NotCMSダッシュボード](https://dash.notcms.com)から取得してください - - -## ステップ4: スキーマを取得 - -NotionデータベースからTypeScript型を生成: +Notionデータベースのスキーマを変更した後は、単独のpullコマンドで更新できます: ```bash -npx notcms-kit pull +npx notcms pull ``` -これにより、`src/notcms/schema.ts`にタイプセーフなスキーマファイルが作成されます: +生成されるスキーマは次のようになります: ```typescript +import { Client } from "notcms"; +import type { Schema } from "notcms"; + export const schema = { blog: { id: "abc123...", @@ -80,17 +70,16 @@ export const schema = { } } } satisfies Schema; + +export const nc = new Client({ schema }); ``` -## ステップ5: NotCMSを使い始める +## ステップ3: NotCMSを使い始める -クライアントを作成してコンテンツのクエリを開始: +生成されたクライアントをimportしてコンテンツのクエリを開始: ```typescript -import { Client } from 'notcms'; -import { schema } from './notcms/schema'; - -const nc = new Client({ schema }); +import { nc } from './notcms/schema'; // 公開されたブログ記事をすべて取得 const [posts, error] = await nc.query.blog.list(); @@ -133,6 +122,6 @@ if (error) { - Notionデータベースに変更を加えた場合は、`npx notcms-kit pull`を再度実行してスキーマを再生成してください。 + Notionデータベースに変更を加えた場合は、`npx notcms pull`を再度実行してスキーマを再生成してください。 diff --git a/docs/ja/usage/initializing-client.mdx b/docs/ja/usage/initializing-client.mdx index 4c1d04f..641712b 100644 --- a/docs/ja/usage/initializing-client.mdx +++ b/docs/ja/usage/initializing-client.mdx @@ -22,7 +22,7 @@ const nc = new Client({ クライアントを初期化する前に、以下を確認してください: -1. **スキーマの生成** - 初回は `npx notcms-kit init` を実行、更新は `npx notcms-kit pull` を実行 +1. **スキーマの生成** - 初回は `npx notcms init` を実行、更新は `npx notcms pull` を実行 2. **環境変数** - API認証情報の設定 3. **NotCMSのインストール** - プロジェクトの依存関係に `notcms` を追加 @@ -133,14 +133,14 @@ export const nc = new Client({ - - `npx notcms-kit pull` を実行してスキーマを生成 + - `npx notcms pull` を実行してスキーマを生成 - インポートパスが正しいことを確認 - スキーマファイルが正しくエクスポートされていることを確認 - NotCMSを最新バージョンに更新 - - `npx notcms-kit pull` でスキーマを再生成 + - `npx notcms pull` でスキーマを再生成 - TypeScriptバージョンの互換性を確認 @@ -156,4 +156,4 @@ export const nc = new Client({ IDによる個別アイテムの取得 - \ No newline at end of file + diff --git a/packages/notcms/README.md b/packages/notcms/README.md index b7737d6..8159dc7 100644 --- a/packages/notcms/README.md +++ b/packages/notcms/README.md @@ -37,14 +37,22 @@ npm install notcms ### CLI Usage -Pull your schema from NotCMS: +Initialize NotCMS from config through the first schema pull: ```bash -# Initialize config npx notcms init +``` + +When credentials are missing, `init` opens browser login, saves them to +`.env.local`, ensures `notcms` is a direct and installed project dependency, +pulls the schema, and prints a runnable first query example. Existing dependency +specs are preserved by running the package manager's plain install command. +Use the standalone commands when you need to log in or refresh the schema later: -# Pull schema from NotCMS +```bash +npx notcms login npx notcms pull +npx notcms pull --check # Check without writing, for CI/CD ``` > **Migration from notcms-kit**: The CLI is now included in the `notcms` package. @@ -53,11 +61,10 @@ npx notcms pull ### SDK Usage ```ts -import { Client } from "notcms"; - -const nc = Client({ schema }); +import { nc } from "./notcms/schema"; -const [pages] = await nc.query.blog.list(); +const [pages, error] = await nc.query.blog.list(); +if (error) throw error; const [page] = await nc.query.blog.get(pages[0].id); ``` diff --git a/packages/notcms/package.json b/packages/notcms/package.json index 52b298d..601f103 100644 --- a/packages/notcms/package.json +++ b/packages/notcms/package.json @@ -16,9 +16,7 @@ "bin": { "notcms": "./dist/cli.cjs" }, - "files": [ - "dist/**/*" - ], + "files": ["dist/**/*"], "scripts": { "build": "tsup", "dev": "tsup --watch", diff --git a/packages/notcms/src/cli.ts b/packages/notcms/src/cli.ts index 6b04743..f184a21 100644 --- a/packages/notcms/src/cli.ts +++ b/packages/notcms/src/cli.ts @@ -1,229 +1,10 @@ -import { promises as fs, existsSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import path from "node:path"; import { config } from "@dotenvx/dotenvx"; -import { confirm, input } from "@inquirer/prompts"; import boxen from "boxen"; import chalk from "chalk"; import { Command } from "commander"; -import dedent from "dedent"; -import { dumpConfig, loadConfig } from "./cli/features/config.js"; -import type { Config } from "./cli/types.js"; - -/** - * Initialize NotCMS - * - Create notcms.config.json - * - Log in via browser when credentials are missing - */ -async function init() { - const config: Config = { - schema: await input({ - message: "Enter the path to save the schema", - default: "src/notcms/schema.ts", - }), - }; - await dumpConfig("notcms.config.json", config); - - console.log( - boxen( - dedent` - NotCMS Config is initialized and saved to ${chalk.blue("notcms.config.json")}. - `, - { - padding: 1, - title: "[ Success ]", - borderColor: "green", - borderStyle: "round", - } - ) - ); - - // NOTE: login depends on the process.env, so it must be imported here - const { getCredentialsFromEnv } = await import("./cli/features/login.js"); - if (!getCredentialsFromEnv()) { - const shouldLogin = await confirm({ - message: - "No NotCMS credentials found. Log in via browser to set them up now?", - default: true, - }); - if (shouldLogin) { - await login(); - } else { - console.log( - boxen( - dedent` - You can log in later with: - - ${chalk.blue("$ npx notcms login")} - - Or set ${chalk.yellow("NOTCMS_SECRET_KEY")} and ${chalk.yellow("NOTCMS_WORKSPACE_ID")} in your env file manually. - `, - { - padding: 1, - title: "[ Info ]", - borderColor: "blue", - borderStyle: "round", - } - ) - ); - } - } - - if (isNextProject()) { - console.log( - boxen( - dedent` - Next.js project detected. - - In order to use next/image with NotCMS, - add the following to your next.config.(js|ts): - - ${boxen( - dedent` - module.exports = { - images: { - remotePatterns: [ - { - protocol: 'https', - hostname: 'api.notcms.com', - port: '', - pathname: '/v1/**', - }, - ], - }, - } - `, - { padding: 1, borderColor: "gray", borderStyle: "round" } - )} - `, - { - padding: 1, - title: "[ Info ]", - borderColor: "blue", - borderStyle: "round", - } - ) - ); - } -} -function isNextProject() { - // js, ts, mjs, cjs - const ext = [".js", ".ts", ".mjs", ".cjs"]; - return ext.some((e) => - existsSync(path.resolve(process.cwd(), `next.config${e}`)) - ); -} - -/** - * Log in to NotCMS via browser - * - Opens the dashboard to mint a secret key - * - Saves NOTCMS_SECRET_KEY and NOTCMS_WORKSPACE_ID to an env file - */ -async function login(options: { write?: string } = {}) { - // NOTE: login depends on the process.env, so it must be imported here - const { loginViaBrowser, saveCredentials } = await import( - "./cli/features/login.js" - ); - const credentials = await loginViaBrowser(); - const savedPath = await saveCredentials(credentials, options.write); - - console.log( - boxen( - dedent` - Logged in to NotCMS. - - ${chalk.yellow("NOTCMS_SECRET_KEY")} and ${chalk.yellow("NOTCMS_WORKSPACE_ID")} are saved to ${chalk.blue(savedPath)}. - - Next, pull your schema: - - ${chalk.blue("$ npx notcms pull")} - `, - { - padding: 1, - title: "[ Success ]", - borderColor: "green", - borderStyle: "round", - } - ) - ); -} - -/** - * Pull schema from NotCMS - * - With --check, verify the local schema is up to date without writing (for CI/CD) - */ -async function pull(options: { check?: boolean } = {}) { - // NOTE: fetchSchema depends on the process.env, so it must be imported here - const { fetchSchema } = await import("./cli/features/schema.js"); - const config = await loadConfig("notcms.config.json"); - const schemaPath = config.schema; - - const schema = await fetchSchema(); - - // NOTE: schema's indent level is different, so cannot use dedent - const content = ` -import { Client } from "notcms"; -import type { Schema } from "notcms"; - -export const schema = ${JSON.stringify(schema, null, 2)} satisfies Schema; -export const nc = new Client({ schema }); - `.trim(); - - if (options.check) { - const existing = await fs.readFile(schemaPath, "utf-8").catch(() => null); - if (existing === content) { - console.log( - boxen(dedent`Schema at ${chalk.blue(schemaPath)} is up to date.`, { - padding: 1, - title: "[ Success ]", - borderColor: "green", - borderStyle: "round", - }) - ); - return; - } - console.log( - boxen( - dedent` - Schema at ${chalk.blue(schemaPath)} is ${existing === null ? "missing" : "out of date"}. - - Run the following to update it: - - ${chalk.blue("$ npx notcms pull")} - `, - { - padding: 1, - title: "[ Check Failed ]", - borderColor: "red", - borderStyle: "round", - } - ) - ); - process.exitCode = 1; - return; - } - - // schemaPath: 'src/notcms/schema.ts' - // make directory if it doesn't exist - await fs.mkdir(schemaPath.split("/").slice(0, -1).join("/"), { - recursive: true, - }); - - await fs.writeFile(schemaPath, content); - - console.log( - boxen( - dedent` - Schema pulled successfully and saved to ${chalk.blue(schemaPath)}. - `, - { - padding: 1, - title: "[ Success ]", - borderColor: "green", - borderStyle: "round", - } - ) - ); -} +import { init, login, pull } from "./cli/commands.js"; function getCliVersion(): string { try { @@ -252,7 +33,7 @@ async function main() { program.option( "-e, --env ", "Specify env file", - (o) => o.split(","), + (option) => option.split(","), DEFAULT_ENV_PATH ); diff --git a/packages/notcms/src/cli/commands.ts b/packages/notcms/src/cli/commands.ts new file mode 100644 index 0000000..c4baf4b --- /dev/null +++ b/packages/notcms/src/cli/commands.ts @@ -0,0 +1,350 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { confirm, input } from "@inquirer/prompts"; +import boxen from "boxen"; +import chalk from "chalk"; +import dedent from "dedent"; +import { dumpConfig } from "./features/config.js"; +import type { DependencySetupResult } from "./features/dependency.js"; +import type { PullSchemaOptions, PullSchemaResult } from "./features/pull.js"; +import type { Config, Credentials } from "./types.js"; + +type LoginResult = { + credentials: Credentials; + savedPath: string; +}; + +/** + * Initialize NotCMS from project config through the first schema pull. + */ +export async function init() { + const config: Config = { + schema: await input({ + message: "Enter the path to save the schema", + default: "src/notcms/schema.ts", + }), + }; + await dumpConfig("notcms.config.json", config); + + console.log( + boxen( + dedent` + NotCMS Config is initialized and saved to ${chalk.blue("notcms.config.json")}. + `, + { + padding: 1, + title: "[ Success ]", + borderColor: "green", + borderStyle: "round", + } + ) + ); + + if (isNextProject()) { + printNextProjectGuidance(); + } + + // NOTE: login depends on process.env, so it must be imported after the CLI + // preAction hook has loaded the configured env files. + const { getCredentialsFromEnv } = await import("./features/login.js"); + let credentials = getCredentialsFromEnv(); + if (!credentials) { + const shouldLogin = await confirm({ + message: + "No NotCMS credentials found. Log in via browser to set them up now?", + default: true, + }); + if (shouldLogin) { + const result = await authenticate(); + credentials = result.credentials; + printLoginSuccess(result, false); + } else { + console.log( + boxen( + dedent` + You can log in later with: + + ${chalk.blue("$ npx notcms login")} + + Or set ${chalk.yellow("NOTCMS_SECRET_KEY")} and ${chalk.yellow("NOTCMS_WORKSPACE_ID")} in your env file manually. + `, + { + padding: 1, + title: "[ Info ]", + borderColor: "blue", + borderStyle: "round", + } + ) + ); + } + } + + if (credentials) { + const { ensureNotcmsDependency } = await import("./features/dependency.js"); + const dependencySetup = await ensureNotcmsDependency(); + await runPull({ credentials }); + if (dependencySetup.status === "manual") { + printManualDependencyGuidance(dependencySetup); + } + } +} + +/** + * Log in to NotCMS via browser and save the returned credentials. + */ +export async function login(options: { write?: string } = {}) { + const result = await authenticate(options.write); + printLoginSuccess(result, true); +} + +/** + * Pull the current workspace schema, optionally checking without writing. + */ +export async function pull(options: { check?: boolean } = {}) { + await runPull({ check: options.check }); +} + +function isNextProject() { + const ext = [".js", ".ts", ".mjs", ".cjs"]; + return ext.some((extension) => + existsSync(path.resolve(process.cwd(), `next.config${extension}`)) + ); +} + +function printNextProjectGuidance() { + console.log( + boxen( + dedent` + Next.js project detected. + + In order to use next/image with NotCMS, + add the following to your next.config.(js|ts): + + ${boxen( + dedent` + module.exports = { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'api.notcms.com', + port: '', + pathname: '/v1/**', + }, + ], + }, + } + `, + { padding: 1, borderColor: "gray", borderStyle: "round" } + )} + `, + { + padding: 1, + title: "[ Info ]", + borderColor: "blue", + borderStyle: "round", + } + ) + ); +} + +async function authenticate(write?: string): Promise { + // NOTE: login depends on process.env, so it must be imported after the CLI + // preAction hook has loaded the configured env files. + const { loginViaBrowser, saveCredentials } = await import( + "./features/login.js" + ); + const credentials = await loginViaBrowser(); + const savedPath = await saveCredentials(credentials, write); + + return { credentials, savedPath }; +} + +function printLoginSuccess(result: LoginResult, showPullHint: boolean) { + const message = showPullHint + ? dedent` + Logged in to NotCMS. + + ${chalk.yellow("NOTCMS_SECRET_KEY")} and ${chalk.yellow("NOTCMS_WORKSPACE_ID")} are saved to ${chalk.blue(result.savedPath)}. + + Next, pull your schema: + + ${chalk.blue("$ npx notcms pull")} + ` + : dedent` + Logged in to NotCMS. + + ${chalk.yellow("NOTCMS_SECRET_KEY")} and ${chalk.yellow("NOTCMS_WORKSPACE_ID")} are saved to ${chalk.blue(result.savedPath)}. + `; + console.log( + boxen(message, { + padding: 1, + title: "[ Success ]", + borderColor: "green", + borderStyle: "round", + }) + ); +} + +function printManualDependencyGuidance( + result: Extract +) { + if (!("command" in result)) { + console.log( + boxen( + dedent` + The schema was generated, but NotCMS could not safely choose a package manager. + + ${result.error} + + Fix the package manager configuration, then install ${chalk.yellow("notcms")} as a direct project dependency. + `, + { + padding: 1, + title: "[ Action required ]", + borderColor: "yellow", + borderStyle: "round", + } + ) + ); + return; + } + + const reason = + result.reason === "failed" + ? dedent` + Automatic installation failed: + ${result.error ?? "Unknown package manager error"} + ` + : result.reason === "declined" + ? "Automatic installation was declined." + : "Automatic installation was skipped in this non-interactive session."; + const dependencyState = + result.verificationRequired === "target-pnp" + ? `The schema was generated, but this Yarn PnP project's ${chalk.yellow("notcms")} resolution has not been verified.` + : `The schema was generated, but the project still needs the ${chalk.yellow("notcms")} package before it can run the first query.`; + console.log( + boxen( + dedent` + ${dependencyState} + + ${reason} + + Install it manually with: + + ${chalk.blue(`$ ${result.command}`)} + `, + { + padding: 1, + title: "[ Action required ]", + borderColor: "yellow", + borderStyle: "round", + } + ) + ); +} + +async function runPull(options: PullSchemaOptions = {}) { + // NOTE: pullSchema depends on process.env for both the API host and optional + // credentials, so it must be imported after the preAction hook has run. + const { pullSchema } = await import("./features/pull.js"); + const result = await pullSchema(options); + printPullResult(result); +} + +function printPullResult(result: PullSchemaResult) { + if (result.status === "stale") { + const reason = result.reason === "missing" ? "missing" : "out of date"; + console.log( + boxen( + dedent` + Schema at ${chalk.blue(result.schemaPath)} is ${reason}. + + Run the following to update it: + + ${chalk.blue("$ npx notcms pull")} + `, + { + padding: 1, + title: "[ Check Failed ]", + borderColor: "red", + borderStyle: "round", + } + ) + ); + process.exitCode = 1; + return; + } + + if (result.status === "up-to-date") { + console.log( + boxen(dedent`Schema at ${chalk.blue(result.schemaPath)} is up to date.`, { + padding: 1, + title: "[ Success ]", + borderColor: "green", + borderStyle: "round", + }) + ); + return; + } + + console.log( + boxen( + dedent` + Schema pulled successfully and saved to ${chalk.blue(result.schemaPath)}. + `, + { + padding: 1, + title: "[ Success ]", + borderColor: "green", + borderStyle: "round", + } + ) + ); + + printFirstQuery(result); +} + +function printFirstQuery( + result: Extract +) { + if (result.firstDatabaseName === null) { + console.log( + boxen( + dedent` + No databases are available in this workspace yet. + + Add and sync a database in the dashboard, then run: + + ${chalk.blue("$ npx notcms pull")} + `, + { + padding: 1, + title: "[ Next step ]", + borderColor: "blue", + borderStyle: "round", + } + ) + ); + return; + } + + const databaseName = JSON.stringify(result.firstDatabaseName); + console.log( + boxen( + dedent` + Try your first query using the ${chalk.blue("nc")} exported from ${chalk.blue(result.schemaPath)}: + + ${chalk.blue(`const [pages, error] = await nc.query[${databaseName}].list();`)} + ${chalk.blue("if (error) throw error;")} + ${chalk.blue("console.log(pages);")} + `, + { + padding: 1, + title: "[ Next step ]", + borderColor: "blue", + borderStyle: "round", + } + ) + ); +} diff --git a/packages/notcms/src/cli/features/dependency-resolution.ts b/packages/notcms/src/cli/features/dependency-resolution.ts new file mode 100644 index 0000000..b21557c --- /dev/null +++ b/packages/notcms/src/cli/features/dependency-resolution.ts @@ -0,0 +1,159 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +// Keep this runtime-resolved so bundlers do not replace the project-scoped +// lookup with the CLI package's own source or bundle location. +const NOTCMS_PACKAGE_NAME = ["not", "cms"].join(""); + +export type NotcmsDependencyState = { + declared: boolean; + resolved: boolean; + verificationRequired?: "target-pnp"; +}; + +type InspectNotcmsDependencyOptions = { + evaluateTargetPnp?: boolean; + targetPnpPath?: string; +}; + +/** Internal adapter for inspecting the target project, not the CLI. */ +export function inspectNotcmsDependency( + cwd: string, + options: InspectNotcmsDependencyOptions = {} +): NotcmsDependencyState { + const resolution = inspectResolution( + cwd, + NOTCMS_PACKAGE_NAME, + options.targetPnpPath, + options.evaluateTargetPnp === true + ); + return { + declared: isDirectDependency(cwd, NOTCMS_PACKAGE_NAME), + ...resolution, + }; +} + +function isDirectDependency(cwd: string, packageName: string): boolean { + try { + const manifest: unknown = JSON.parse( + readFileSync(path.join(cwd, "package.json"), "utf-8") + ); + if (!isRecord(manifest)) { + return false; + } + return ( + hasOwnDependency(manifest.dependencies, packageName) || + hasOwnDependency(manifest.devDependencies, packageName) + ); + } catch { + return false; + } +} + +function hasOwnDependency(value: unknown, packageName: string): boolean { + return ( + isRecord(value) && Object.prototype.hasOwnProperty.call(value, packageName) + ); +} + +function inspectResolution( + cwd: string, + packageName: string, + targetPnpPath: string | undefined, + evaluateTargetPnp: boolean +): Pick { + const packageDirectories = ancestorDirectories(cwd) + .map((directory) => + path.join(directory, "node_modules", ...packageName.split("/")) + ) + .filter((directory) => existsSync(directory)); + if (packageDirectories.length > 0) { + try { + const resolved = createRequire(path.join(cwd, "package.json")).resolve( + packageName + ); + const realResolved = realpathSync(resolved); + if ( + packageDirectories.some((directory) => + isWithin(realpathSync(directory), realResolved) + ) + ) { + return { resolved: true }; + } + } catch { + // Fall through to target-project PnP detection. + } + } + + if (!targetPnpPath || !existsSync(targetPnpPath)) { + return { resolved: false }; + } + if (!evaluateTargetPnp) { + return { resolved: false, verificationRequired: "target-pnp" }; + } + return { + resolved: isResolvableThroughTargetPnp(cwd, packageName, targetPnpPath), + }; +} + +/** + * Resolve through the target project's generated PnP API rather than relying + * on the CLI process having started with Yarn's loader. The returned path may + * live inside Yarn's zip filesystem, so a successful API resolution is the + * existence check; native `fs.existsSync` cannot inspect that virtual path. + */ +function isResolvableThroughTargetPnp( + cwd: string, + packageName: string, + pnpPath: string +): boolean { + try { + const projectRequire = createRequire(path.join(cwd, "package.json")); + const resolvedPnpPath = projectRequire.resolve(pnpPath); + delete projectRequire.cache[resolvedPnpPath]; + const pnpApi: unknown = projectRequire(resolvedPnpPath); + if (!isRecord(pnpApi)) { + return false; + } + const resolveRequest = Reflect.get(pnpApi, "resolveRequest"); + if (typeof resolveRequest !== "function") { + return false; + } + const resolved: unknown = Reflect.apply(resolveRequest, pnpApi, [ + packageName, + path.join(cwd, "package.json"), + { considerBuiltins: false }, + ]); + return typeof resolved === "string" && resolved.length > 0; + } catch { + return false; + } +} + +function ancestorDirectories(start: string): string[] { + const directories: string[] = []; + let directory = path.resolve(start); + while (true) { + directories.push(directory); + const parent = path.dirname(directory); + if (parent === directory) { + return directories; + } + directory = parent; + } +} + +function isWithin(parent: string, candidate: string): boolean { + const relative = path.relative(parent, candidate); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/notcms/src/cli/features/dependency.ts b/packages/notcms/src/cli/features/dependency.ts new file mode 100644 index 0000000..b03a35b --- /dev/null +++ b/packages/notcms/src/cli/features/dependency.ts @@ -0,0 +1,546 @@ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { confirm } from "@inquirer/prompts"; +import { inspectNotcmsDependency } from "./dependency-resolution.js"; + +export type PackageManager = "pnpm" | "npm" | "yarn" | "bun"; + +export type DependencySetupResult = + | { status: "already-installed" } + | { + status: "installed"; + packageManager: PackageManager; + command: string; + } + | { + status: "manual"; + reason: "declined" | "failed" | "non-interactive"; + packageManager: PackageManager; + command: string; + error?: string; + verificationRequired?: "target-pnp"; + } + | { + status: "manual"; + reason: "ambiguous-lockfiles" | "invalid-package-manager"; + error: string; + }; + +export type PackageManagerDetection = + | { + status: "detected"; + packageManager: PackageManager; + packageManagerMajor?: number; + } + | { + status: "manual"; + reason: "ambiguous-lockfiles" | "invalid-package-manager"; + error: string; + }; + +type DetectPackageManagerOptions = { + cwd?: string; + userAgent?: string; +}; + +type EnsureNotcmsDependencyOptions = DetectPackageManagerOptions & { + interactive?: boolean; +}; + +type PackageManagerDetectionContext = { + detection: PackageManagerDetection; + projectDirectory?: string; +}; + +type PackageManagerInvocation = { + executable: string; + args: string[]; + windowsVerbatimArguments?: true; +}; + +const LOCKFILES: ReadonlyArray< + readonly [filename: string, packageManager: PackageManager] +> = [ + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["bun.lock", "bun"], + ["bun.lockb", "bun"], + ["package-lock.json", "npm"], + ["npm-shrinkwrap.json", "npm"], +]; + +const ADD_ARGS: Record = { + pnpm: ["add", "notcms"], + npm: ["install", "notcms"], + yarn: ["add", "notcms"], + bun: ["add", "notcms"], +}; + +/** + * Detect the package manager without ever treating project-controlled text as + * an executable. The nearest directory containing a packageManager field or + * lockfile defines the project boundary. Within that directory an explicit + * packageManager wins, then the invoking package manager's user agent helps + * disambiguate lockfiles, with npm as the portable final default. + */ +export function detectPackageManager( + options: DetectPackageManagerOptions = {} +): PackageManagerDetection { + return detectPackageManagerContext(options).detection; +} + +function detectPackageManagerContext( + options: DetectPackageManagerOptions = {} +): PackageManagerDetectionContext { + const cwd = path.resolve(options.cwd ?? process.cwd()); + const directories = ancestorDirectories(cwd); + const userAgent = + options.userAgent ?? process.env.npm_config_user_agent ?? ""; + const userAgentManager = parseUserAgent(userAgent); + + for (const directory of directories) { + const packageManagerField = readPackageManagerField(directory); + if (packageManagerField.status === "valid") { + return { + detection: { + status: "detected", + packageManager: packageManagerField.packageManager, + ...(packageManagerField.packageManager !== "yarn" || + packageManagerField.packageManagerMajor === undefined + ? {} + : { packageManagerMajor: packageManagerField.packageManagerMajor }), + }, + projectDirectory: directory, + }; + } + if (packageManagerField.status === "invalid") { + return { + detection: { + status: "manual", + reason: "invalid-package-manager", + error: `The packageManager field in ${packageManagerField.manifestPath} is invalid or unsupported. Use pnpm, npm, yarn, or bun.`, + }, + projectDirectory: directory, + }; + } + const packageManagers = lockfilePackageManagers(directory); + if (packageManagers.length === 0) { + continue; + } + if (userAgentManager && packageManagers.includes(userAgentManager)) { + const packageManagerMajor = + userAgentManager === "yarn" + ? parseUserAgentMajor(userAgent) + : undefined; + return { + detection: { + status: "detected", + packageManager: userAgentManager, + ...(packageManagerMajor === undefined ? {} : { packageManagerMajor }), + }, + projectDirectory: directory, + }; + } + if (packageManagers.length > 1) { + return { + detection: { + status: "manual", + reason: "ambiguous-lockfiles", + error: `Multiple package-manager lockfiles were found in ${directory}: ${packageManagers.join(", ")}. Remove stale lockfiles or set a valid packageManager field.`, + }, + projectDirectory: directory, + }; + } + const packageManager = packageManagers[0]; + const packageManagerMajor = + packageManager === "yarn" + ? inferYarnMajorFromProject(directory) + : undefined; + return { + detection: { + status: "detected", + packageManager, + ...(packageManagerMajor === undefined ? {} : { packageManagerMajor }), + }, + projectDirectory: directory, + }; + } + + const packageManagerMajor = + userAgentManager === "yarn" ? parseUserAgentMajor(userAgent) : undefined; + return { + detection: { + status: "detected", + packageManager: userAgentManager ?? "npm", + ...(packageManagerMajor === undefined ? {} : { packageManagerMajor }), + }, + }; +} + +/** + * Ensure generated schema modules can resolve their public `notcms` import. + * Installation is offered only in an interactive terminal; all other outcomes + * are returned so the caller can finish schema generation and print one final + * manual command when needed. + */ +export async function ensureNotcmsDependency( + options: EnsureNotcmsDependencyOptions = {} +): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()); + const safeDependency = inspectNotcmsDependency(cwd); + if (safeDependency.declared && safeDependency.resolved) { + return { status: "already-installed" }; + } + + const detectionContext = detectPackageManagerContext({ + cwd, + userAgent: options.userAgent, + }); + const { detection } = detectionContext; + if (detection.status === "manual") { + return detection; + } + + const packageManager = detection.packageManager; + const targetPnpPath = + packageManager === "yarn" && detectionContext.projectDirectory + ? existingTargetPnpPath(detectionContext.projectDirectory) + : undefined; + const dependency = targetPnpPath + ? inspectNotcmsDependency(cwd, { targetPnpPath }) + : safeDependency; + + if ( + !dependency.declared && + packageManager === "yarn" && + isYarnWorkspaceRoot(cwd) && + detection.packageManagerMajor === undefined + ) { + return { + status: "manual", + reason: "invalid-package-manager", + error: + "NotCMS could not determine whether this Yarn workspace uses Yarn Classic or Yarn Berry. Set packageManager to yarn@ in package.json, then run init again.", + }; + } + const args = dependency.declared + ? ["install"] + : addDependencyArgs(packageManager, cwd, detection.packageManagerMajor); + const command = [packageManager, ...args].join(" "); + const interactive = options.interactive ?? isInteractiveTerminal(); + + if (!interactive) { + return { + status: "manual", + reason: "non-interactive", + packageManager, + command, + ...(dependency.verificationRequired + ? { verificationRequired: dependency.verificationRequired } + : {}), + }; + } + + const message = dependency.declared + ? dependency.verificationRequired === "target-pnp" + ? `The notcms dependency is declared in a Yarn PnP project, but its resolution has not been verified. Run "${command}" now, then verify it?` + : `The notcms dependency is declared but not installed. Run "${command}" now?` + : dependency.verificationRequired === "target-pnp" + ? `The notcms package is not a direct project dependency. Run "${command}" now, then verify the Yarn PnP resolution?` + : `The notcms package is not a direct project dependency. Run "${command}" now?`; + const accepted = await confirm({ + message, + default: true, + }); + if (!accepted) { + return { + status: "manual", + reason: "declined", + packageManager, + command, + ...(dependency.verificationRequired + ? { verificationRequired: dependency.verificationRequired } + : {}), + }; + } + + try { + await runInstall(packageManager, args, cwd); + } catch (error) { + return { + status: "manual", + reason: "failed", + packageManager, + command, + error: error instanceof Error ? error.message : String(error), + ...(dependency.verificationRequired + ? { verificationRequired: dependency.verificationRequired } + : {}), + }; + } + + const installedDetectionContext = detectPackageManagerContext({ + cwd, + userAgent: options.userAgent, + }); + const installedDetection = installedDetectionContext.detection; + const installedTargetPnpPath = + packageManager === "yarn" && + installedDetection.status === "detected" && + installedDetection.packageManager === "yarn" && + installedDetectionContext.projectDirectory + ? existingTargetPnpPath(installedDetectionContext.projectDirectory) + : undefined; + const installedDependency = inspectNotcmsDependency(cwd, { + ...(installedTargetPnpPath + ? { + evaluateTargetPnp: true, + targetPnpPath: installedTargetPnpPath, + } + : {}), + }); + if (!installedDependency.declared || !installedDependency.resolved) { + return { + status: "manual", + reason: "failed", + packageManager, + command, + error: `${command} exited successfully, but notcms is still not a direct, resolvable project dependency.`, + }; + } + return { status: "installed", packageManager, command }; +} + +function existingTargetPnpPath(projectDirectory: string): string | undefined { + const pnpPath = path.join(projectDirectory, ".pnp.cjs"); + return existsSync(pnpPath) ? pnpPath : undefined; +} + +function ancestorDirectories(start: string): string[] { + const directories: string[] = []; + let directory = start; + while (true) { + directories.push(directory); + const parent = path.dirname(directory); + if (parent === directory) { + return directories; + } + directory = parent; + } +} + +type PackageManagerField = + | { status: "absent" } + | { + status: "valid"; + packageManager: PackageManager; + packageManagerMajor?: number; + } + | { status: "invalid"; manifestPath: string }; + +function readPackageManagerField(directory: string): PackageManagerField { + const manifestPath = path.join(directory, "package.json"); + try { + const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf-8")); + if ( + !isRecord(parsed) || + !Object.prototype.hasOwnProperty.call(parsed, "packageManager") + ) { + return { status: "absent" }; + } + if (typeof parsed.packageManager !== "string") { + return { status: "invalid", manifestPath }; + } + const packageManagerSpec = parsePackageManager(parsed.packageManager); + return packageManagerSpec + ? { status: "valid", ...packageManagerSpec } + : { status: "invalid", manifestPath }; + } catch { + return { status: "absent" }; + } +} + +function lockfilePackageManagers(directory: string): PackageManager[] { + const packageManagers: PackageManager[] = []; + for (const [filename, packageManager] of LOCKFILES) { + if ( + existsSync(path.join(directory, filename)) && + !packageManagers.includes(packageManager) + ) { + packageManagers.push(packageManager); + } + } + return packageManagers; +} + +function parsePackageManager(value: string): { + packageManager: PackageManager; + packageManagerMajor?: number; +} | null { + const match = /^(pnpm|npm|yarn|bun)(?:@([0-9A-Za-z][0-9A-Za-z.+_-]*))?$/.exec( + value.trim() + ); + const packageManager = match ? packageManagerFromName(match[1]) : null; + if (!packageManager) { + return null; + } + const packageManagerMajor = match?.[2] + ? parseLeadingMajor(match[2]) + : undefined; + return { + packageManager, + ...(packageManagerMajor === undefined ? {} : { packageManagerMajor }), + }; +} + +function parseUserAgent(userAgent: string): PackageManager | null { + const match = /^(pnpm|npm|yarn|bun)\//.exec(userAgent.trim()); + return match ? packageManagerFromName(match[1]) : null; +} + +function parseUserAgentMajor(userAgent: string): number | undefined { + const match = /^(?:pnpm|npm|yarn|bun)\/([^\s]+)/.exec(userAgent.trim()); + return match ? parseLeadingMajor(match[1]) : undefined; +} + +function parseLeadingMajor(value: string): number | undefined { + const match = /^(\d+)/.exec(value); + if (!match) { + return undefined; + } + const major = Number(match[1]); + return Number.isSafeInteger(major) ? major : undefined; +} + +function inferYarnMajorFromProject(directory: string): number | undefined { + if (existsSync(path.join(directory, ".yarnrc.yml"))) { + return 2; + } + try { + const lockfile = readFileSync(path.join(directory, "yarn.lock"), "utf-8"); + if (/^# yarn lockfile v1\s*$/m.test(lockfile)) { + return 1; + } + if (/^__metadata:\s*$/m.test(lockfile)) { + return 2; + } + } catch { + // A missing or unreadable lockfile leaves the Yarn generation unknown. + } + return undefined; +} + +function packageManagerFromName(value: string): PackageManager | null { + switch (value) { + case "pnpm": + case "npm": + case "yarn": + case "bun": + return value; + default: + return null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isInteractiveTerminal(): boolean { + const ci = process.env.CI?.trim().toLowerCase(); + const isCi = ci !== undefined && ci !== "" && ci !== "false" && ci !== "0"; + return !isCi && process.stdin.isTTY === true && process.stdout.isTTY === true; +} + +function addDependencyArgs( + packageManager: PackageManager, + cwd: string, + packageManagerMajor: number | undefined +): readonly string[] { + if ( + packageManager === "pnpm" && + existsSync(path.join(cwd, "pnpm-workspace.yaml")) + ) { + return ["add", "-w", "notcms"]; + } + if ( + packageManager === "yarn" && + packageManagerMajor === 1 && + isYarnWorkspaceRoot(cwd) + ) { + return ["add", "-W", "notcms"]; + } + return ADD_ARGS[packageManager]; +} + +function isYarnWorkspaceRoot(cwd: string): boolean { + try { + const manifest: unknown = JSON.parse( + readFileSync(path.join(cwd, "package.json"), "utf-8") + ); + return ( + isRecord(manifest) && + Object.prototype.hasOwnProperty.call(manifest, "workspaces") + ); + } catch { + return false; + } +} + +/** + * Build the child-process invocation without enabling Node's shell mode. + * Windows command shims still require cmd.exe, so only fixed, validated tokens + * are placed into its command string and passed with verbatim argument quoting. + */ +export function createPackageManagerInvocation( + packageManager: PackageManager, + args: readonly string[], + platform: NodeJS.Platform = process.platform, + comspec = process.env.ComSpec ?? + process.env.COMSPEC ?? + process.env.comspec ?? + "cmd.exe" +): PackageManagerInvocation { + if (platform !== "win32") { + return { executable: packageManager, args: [...args] }; + } + + const tokens = [`${packageManager}.cmd`, ...args]; + if (!tokens.every((token) => /^[0-9A-Za-z._-]+$/.test(token))) { + throw new Error("Unsafe package-manager argument for Windows."); + } + + return { + executable: comspec, + args: ["/d", "/s", "/c", `"${tokens.join(" ")}"`], + windowsVerbatimArguments: true, + }; +} + +function runInstall( + packageManager: PackageManager, + args: readonly string[], + cwd: string +): Promise { + const invocation = createPackageManagerInvocation(packageManager, args); + return new Promise((resolve, reject) => { + const child = spawn(invocation.executable, invocation.args, { + cwd, + shell: false, + stdio: "inherit", + ...(invocation.windowsVerbatimArguments + ? { windowsVerbatimArguments: true } + : {}), + }); + child.once("error", reject); + child.once("close", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + const outcome = signal ? `signal ${signal}` : `exit code ${code}`; + reject(new Error(`${packageManager} install failed with ${outcome}.`)); + }); + }); +} diff --git a/packages/notcms/src/cli/features/login.ts b/packages/notcms/src/cli/features/login.ts index 23e84ab..4f5b9d7 100644 --- a/packages/notcms/src/cli/features/login.ts +++ b/packages/notcms/src/cli/features/login.ts @@ -3,6 +3,7 @@ import { promises as fs } from "node:fs"; import * as path from "node:path"; import chalk from "chalk"; import dedent from "dedent"; +import type { Credentials } from "../types.js"; import { getApiHost } from "../variables.js"; const CLI_DEVICE_CLIENT_ID = "notcms-cli"; @@ -20,11 +21,6 @@ type DeviceAuthorization = { interval: number; }; -export type Credentials = { - secretKey: string; - workspaceId: string; -}; - export function getCredentialsFromEnv(): Credentials | null { const { NOTCMS_SECRET_KEY, NOTCMS_WORKSPACE_ID } = process.env; if (!NOTCMS_SECRET_KEY || !NOTCMS_WORKSPACE_ID) { diff --git a/packages/notcms/src/cli/features/pull.ts b/packages/notcms/src/cli/features/pull.ts new file mode 100644 index 0000000..a5525ef --- /dev/null +++ b/packages/notcms/src/cli/features/pull.ts @@ -0,0 +1,98 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import type { Schema } from "../../types.js"; +import type { Credentials } from "../types.js"; +import { loadConfig } from "./config.js"; +import { fetchSchemaResponse } from "./schema.js"; + +export type PullSchemaOptions = { + check?: boolean; + credentials?: Credentials; +}; + +type WrittenSchemaResult = { + status: "written"; + schemaPath: string; + firstDatabaseName: string | null; +}; + +type UpToDateSchemaResult = { + status: "up-to-date"; + schemaPath: string; + firstDatabaseName: string | null; +}; + +type StaleSchemaResult = { + status: "stale"; + schemaPath: string; + reason: "missing" | "out-of-date"; +}; + +export type PullSchemaResult = + | WrittenSchemaResult + | UpToDateSchemaResult + | StaleSchemaResult; + +/** + * Fetch the workspace schema and either write it locally or compare it with + * the existing file. Newly minted credentials can be passed directly; normal + * `pull` calls continue to use credentials loaded into the environment. + */ +export async function pullSchema( + options: PullSchemaOptions = {} +): Promise { + const config = await loadConfig("notcms.config.json"); + const schemaPath = config.schema; + const absoluteSchemaPath = path.resolve(schemaPath); + const schemaResponse = await fetchSchemaResponse(options.credentials); + const schema = schemaResponse.schema; + const content = createSchemaModule(schema); + const firstDatabaseName = + schemaResponse.onboardingDatabaseName === undefined + ? (Object.keys(schema)[0] ?? null) + : schemaResponse.onboardingDatabaseName; + + if (options.check) { + const existing = await fs + .readFile(absoluteSchemaPath, "utf-8") + .catch(() => null); + if (existing === content) { + return { status: "up-to-date", schemaPath, firstDatabaseName }; + } + return { + status: "stale", + schemaPath, + reason: existing === null ? "missing" : "out-of-date", + }; + } + + await fs.mkdir(path.dirname(absoluteSchemaPath), { recursive: true }); + await fs.writeFile(absoluteSchemaPath, content); + + return { status: "written", schemaPath, firstDatabaseName }; +} + +function createSchemaModule(schema: Schema): string { + // The schema JSON has a different indentation level from the imports, so a + // template literal is clearer than dedenting the generated module. + return ` +import { Client } from "notcms"; +import type { Schema } from "notcms"; + +export const schema = ${serializeSchemaAsObjectLiteral(schema)} satisfies Schema; +export const nc = new Client({ schema }); + `.trim(); +} + +/** + * JSON is almost a valid TypeScript object literal, except that a `__proto__` + * property followed by `:` changes an object's prototype instead of defining + * an own property. Computed syntax preserves the exact database/property name + * while retaining the generated schema's literal types. + */ +function serializeSchemaAsObjectLiteral(schema: Schema): string { + return JSON.stringify(schema, null, 2).replace( + /^(\s*)"__proto__":/gm, + '$1["__proto__"]:' + ); +} diff --git a/packages/notcms/src/cli/features/schema.ts b/packages/notcms/src/cli/features/schema.ts index cc839fa..460f6fd 100644 --- a/packages/notcms/src/cli/features/schema.ts +++ b/packages/notcms/src/cli/features/schema.ts @@ -2,6 +2,7 @@ import chalk from "chalk"; import dedent from "dedent"; import { PROPERTY_TYPES, type Properties, type Schema } from "../../types.js"; import { routes } from "../routes.js"; +import type { Credentials } from "../types.js"; const DASHBOARD_URL = "https://dash.notcms.com/"; @@ -31,16 +32,30 @@ function isSchemaEntry(entry: unknown): entry is Schema[string] { function isSchema(value: unknown): value is Schema { return isPlainObject(value) && Object.values(value).every(isSchemaEntry); } -export async function fetchSchema(): Promise { - const { NOTCMS_SECRET_KEY, NOTCMS_WORKSPACE_ID } = process.env; - if (!NOTCMS_SECRET_KEY || !NOTCMS_WORKSPACE_ID) { + +export type FetchedSchemaResponse = { + onboardingDatabaseName?: string | null; + schema: Schema; +}; + +export async function fetchSchema(credentials?: Credentials): Promise { + return (await fetchSchemaResponse(credentials)).schema; +} + +export async function fetchSchemaResponse( + credentials?: Credentials +): Promise { + const secretKey = credentials?.secretKey ?? process.env.NOTCMS_SECRET_KEY; + const workspaceId = + credentials?.workspaceId ?? process.env.NOTCMS_WORKSPACE_ID; + if (!secretKey || !workspaceId) { throw new Error( dedent` Both ${chalk.yellow("NOTCMS_SECRET_KEY")} and ${chalk.yellow("NOTCMS_WORKSPACE_ID")} must be set. ${chalk.bold("Got:")} - NOTCMS_WORKSPACE_ID: ${NOTCMS_WORKSPACE_ID ? chalk.green(NOTCMS_WORKSPACE_ID) : chalk.red(NOTCMS_WORKSPACE_ID)} - NOTCMS_SECRET_KEY: ${NOTCMS_SECRET_KEY ? chalk.green("(set)") : chalk.red(NOTCMS_SECRET_KEY)} + NOTCMS_WORKSPACE_ID: ${workspaceId ? chalk.green(workspaceId) : chalk.red(workspaceId)} + NOTCMS_SECRET_KEY: ${secretKey ? chalk.green("(set)") : chalk.red(secretKey)} ${chalk.bold("Suggested action:")} 1. Get your key and id from the dashboard. @@ -48,7 +63,7 @@ export async function fetchSchema(): Promise { 2. Set them in your environment variables, and make sure they are loaded. Example: - ${chalk.blue("$ echo 'NOTCMS_SECRET=your_secret_key' >> .env")} + ${chalk.blue("$ echo 'NOTCMS_SECRET_KEY=your_secret_key' >> .env")} ${chalk.blue("$ echo 'NOTCMS_WORKSPACE_ID=your_workspace_id' >> .env")} ${chalk.blue("$ source .env")} @@ -57,22 +72,44 @@ export async function fetchSchema(): Promise { ` ); } - const res = await fetch(routes.schema(NOTCMS_WORKSPACE_ID), { + const res = await fetch(routes.schema(workspaceId), { method: "GET", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${NOTCMS_SECRET_KEY}`, + Authorization: `Bearer ${secretKey}`, }, }); try { if (!res.ok) { throw new Error(`Unexpected status ${res.status}`); } - const data = (await res.json()) as { schema?: unknown }; - if (!isSchema(data.schema)) { + const data: unknown = await res.json(); + if (!isPlainObject(data) || !isSchema(data.schema)) { throw new Error("Invalid schema payload."); } - return data.schema; + const hasOnboardingDatabaseName = Object.prototype.hasOwnProperty.call( + data, + "onboardingDatabaseName" + ); + const onboardingDatabaseName = data.onboardingDatabaseName; + if ( + hasOnboardingDatabaseName && + onboardingDatabaseName !== null && + typeof onboardingDatabaseName !== "string" + ) { + throw new Error("Invalid onboarding database name."); + } + return { + schema: data.schema, + ...(hasOnboardingDatabaseName + ? { + onboardingDatabaseName: + typeof onboardingDatabaseName === "string" + ? onboardingDatabaseName + : null, + } + : {}), + }; } catch (error) { throw new Error( dedent` diff --git a/packages/notcms/src/cli/types.ts b/packages/notcms/src/cli/types.ts index f692a90..acc8b4e 100644 --- a/packages/notcms/src/cli/types.ts +++ b/packages/notcms/src/cli/types.ts @@ -2,6 +2,11 @@ export type Config = { schema: string; }; +export type Credentials = { + secretKey: string; + workspaceId: string; +}; + export function isConfig(obj: Record): obj is Config { return typeof obj.schema === "string"; } diff --git a/packages/notcms/src/client.ts b/packages/notcms/src/client.ts index c1e0691..fe6d435 100644 --- a/packages/notcms/src/client.ts +++ b/packages/notcms/src/client.ts @@ -130,7 +130,7 @@ export class Client { } this.secretKey = secretKey; this.workspaceId = workspaceId; - this.query = {} as { + this.query = Object.create(null) as { [K in keyof TSchema]: DatabaseHandler< InferProperties >; @@ -139,12 +139,17 @@ export class Client { if (!schema) { throw new Error("schema is required."); } - for (const key in schema) { - this.query[key as keyof TSchema] = new DatabaseHandler( - this.secretKey, - this.workspaceId, - schema[key].id - ); + for (const key of Object.keys(schema)) { + Object.defineProperty(this.query, key, { + configurable: true, + enumerable: true, + value: new DatabaseHandler( + this.secretKey, + this.workspaceId, + schema[key].id + ), + writable: true, + }); } } } diff --git a/packages/notcms/test/cli-dependency-pnp-security.spec.ts b/packages/notcms/test/cli-dependency-pnp-security.spec.ts new file mode 100644 index 0000000..bd38d07 --- /dev/null +++ b/packages/notcms/test/cli-dependency-pnp-security.spec.ts @@ -0,0 +1,333 @@ +import { EventEmitter } from "node:events"; +import { promises as fs, existsSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const promptMocks = vi.hoisted(() => ({ + confirm: vi.fn(), +})); +const childProcessMocks = vi.hoisted(() => ({ + spawn: vi.fn(), +})); + +vi.mock("@inquirer/prompts", () => promptMocks); +vi.mock("node:child_process", () => childProcessMocks); + +import { ensureNotcmsDependency } from "../src/cli/features/dependency"; + +describe("Yarn PnP dependency verification consent boundary", () => { + let dir: string; + let initialExecutionMarker: string; + let postInstallExecutionMarker: string; + let pnpPath: string; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(tmpdir(), "notcms-pnp-security-")); + initialExecutionMarker = path.join(dir, "initial-pnp-executed"); + postInstallExecutionMarker = path.join(dir, "post-install-pnp-executed"); + pnpPath = path.join(dir, ".pnp.cjs"); + await fs.writeFile( + path.join(dir, "package.json"), + JSON.stringify({ + name: "notcms-pnp-security-fixture", + packageManager: "yarn@4.9.2", + dependencies: { notcms: "1.0.0" }, + }) + ); + await writeExecutablePnpFixture(pnpPath, initialExecutionMarker, false); + }); + + afterEach(async () => { + vi.clearAllMocks(); + await fs.rm(dir, { recursive: true, force: true }); + }); + + it("does not execute target PnP code in a non-interactive session", async () => { + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: false, + userAgent: "yarn/4.9.2", + }) + ).resolves.toEqual({ + status: "manual", + reason: "non-interactive", + packageManager: "yarn", + command: "yarn install", + verificationRequired: "target-pnp", + }); + + expect(existsSync(initialExecutionMarker)).toBe(false); + expect(promptMocks.confirm).not.toHaveBeenCalled(); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + }); + + it("does not execute target PnP code when verification is declined", async () => { + promptMocks.confirm.mockResolvedValue(false); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "yarn/4.9.2", + }) + ).resolves.toEqual( + expect.objectContaining({ + status: "manual", + reason: "declined", + verificationRequired: "target-pnp", + }) + ); + + expect(existsSync(initialExecutionMarker)).toBe(false); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + }); + + it("does not execute target PnP code when the approved install fails", async () => { + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => closingChild(7)); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "yarn/4.9.2", + }) + ).resolves.toEqual( + expect.objectContaining({ + status: "manual", + reason: "failed", + verificationRequired: "target-pnp", + }) + ); + + expect(existsSync(initialExecutionMarker)).toBe(false); + }); + + it("loads only the fresh target PnP API after an approved install succeeds", async () => { + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => { + const child = new EventEmitter(); + queueMicrotask(() => { + writeExecutablePnpFixtureSync( + pnpPath, + postInstallExecutionMarker, + true + ); + child.emit("close", 0, null); + }); + return child; + }); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "yarn/4.9.2", + }) + ).resolves.toEqual({ + status: "installed", + packageManager: "yarn", + command: "yarn install", + }); + + expect(existsSync(initialExecutionMarker)).toBe(false); + expect(existsSync(postInstallExecutionMarker)).toBe(true); + }); + + it("verifies a target PnP map first generated by the approved install", async () => { + await fs.rm(pnpPath); + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => { + const child = new EventEmitter(); + queueMicrotask(() => { + writeExecutablePnpFixtureSync( + pnpPath, + postInstallExecutionMarker, + true + ); + child.emit("close", 0, null); + }); + return child; + }); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "yarn/4.9.2", + }) + ).resolves.toEqual({ + status: "installed", + packageManager: "yarn", + command: "yarn install", + }); + + expect(existsSync(postInstallExecutionMarker)).toBe(true); + expect(promptMocks.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("declared but not installed"), + }) + ); + }); + + it("redetects a Yarn boundary first generated by the approved add", async () => { + await fs.rm(pnpPath); + await fs.writeFile( + path.join(dir, "package.json"), + JSON.stringify({ + name: "notcms-fresh-yarn-fixture", + private: true, + }) + ); + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => { + const child = new EventEmitter(); + queueMicrotask(() => { + writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ + name: "notcms-fresh-yarn-fixture", + private: true, + dependencies: { notcms: "1.0.0" }, + }) + ); + writeFileSync( + path.join(dir, "yarn.lock"), + "__metadata:\n version: 8\n" + ); + writeExecutablePnpFixtureSync( + pnpPath, + postInstallExecutionMarker, + true + ); + child.emit("close", 0, null); + }); + return child; + }); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "yarn/4.9.2", + }) + ).resolves.toEqual({ + status: "installed", + packageManager: "yarn", + command: "yarn add notcms", + }); + + expect(existsSync(postInstallExecutionMarker)).toBe(true); + }); + + it("accepts a safely resolvable direct dependency before parsing stale package-manager metadata", async () => { + await fs.writeFile( + path.join(dir, "package.json"), + JSON.stringify({ + name: "notcms-installed-invalid-manager-fixture", + packageManager: "yarn; execute-project-code", + dependencies: { notcms: "1.0.0" }, + }) + ); + const packageDirectory = path.join(dir, "node_modules", "notcms"); + await fs.mkdir(packageDirectory, { recursive: true }); + await fs.writeFile( + path.join(packageDirectory, "package.json"), + JSON.stringify({ name: "notcms", main: "index.js" }) + ); + await fs.writeFile(path.join(packageDirectory, "index.js"), "export {};"); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "npm/11.0.0", + }) + ).resolves.toEqual({ status: "already-installed" }); + + expect(existsSync(initialExecutionMarker)).toBe(false); + expect(promptMocks.confirm).not.toHaveBeenCalled(); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + }); + + it.each([ + ["npm", "11.0.0"], + ["pnpm", "10.0.0"], + ["bun", "1.2.0"], + ] as const)( + "does not inspect an ancestor PnP map after an approved %s install", + async (packageManager, version) => { + const nestedProject = path.join(dir, packageManager); + await fs.mkdir(nestedProject); + await fs.writeFile( + path.join(nestedProject, "package.json"), + JSON.stringify({ + name: `notcms-${packageManager}-fixture`, + packageManager: `${packageManager}@${version}`, + dependencies: { notcms: "1.0.0" }, + }) + ); + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => closingChild(0)); + + await expect( + ensureNotcmsDependency({ + cwd: nestedProject, + interactive: true, + userAgent: `${packageManager}/${version}`, + }) + ).resolves.toEqual({ + status: "manual", + reason: "failed", + packageManager, + command: `${packageManager} install`, + error: `${packageManager} install exited successfully, but notcms is still not a direct, resolvable project dependency.`, + }); + + expect(existsSync(initialExecutionMarker)).toBe(false); + expect(promptMocks.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.not.stringContaining("Yarn PnP"), + }) + ); + } + ); +}); + +async function writeExecutablePnpFixture( + pnpPath: string, + markerPath: string, + resolvesNotcms: boolean +) { + await fs.writeFile(pnpPath, executablePnpFixture(markerPath, resolvesNotcms)); +} + +function writeExecutablePnpFixtureSync( + pnpPath: string, + markerPath: string, + resolvesNotcms: boolean +) { + writeFileSync(pnpPath, executablePnpFixture(markerPath, resolvesNotcms)); +} + +function executablePnpFixture( + markerPath: string, + resolvesNotcms: boolean +): string { + return `require("node:fs").writeFileSync(${JSON.stringify(markerPath)}, "executed"); +module.exports = { + resolveRequest(request) { + if (request !== "notcms" || ${JSON.stringify(!resolvesNotcms)}) { + throw new Error("notcms is unresolved"); + } + return "/virtual/.yarn/cache/notcms.zip/node_modules/notcms/dist/index.js"; + }, +};`; +} + +function closingChild(exitCode: number) { + const child = new EventEmitter(); + queueMicrotask(() => child.emit("close", exitCode, null)); + return child; +} diff --git a/packages/notcms/test/cli-dependency-resolution.spec.ts b/packages/notcms/test/cli-dependency-resolution.spec.ts new file mode 100644 index 0000000..00b3b79 --- /dev/null +++ b/packages/notcms/test/cli-dependency-resolution.spec.ts @@ -0,0 +1,163 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { inspectNotcmsDependency } from "../src/cli/features/dependency-resolution"; + +describe("inspectNotcmsDependency", () => { + let dir: string; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(tmpdir(), "notcms-resolution-")); + }); + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + it.each(["dependencies", "devDependencies"])( + "recognizes a direct declaration in %s", + async (field) => { + await writeManifest({ [field]: { notcms: "file:../notcms" } }); + + expect(inspectNotcmsDependency(dir).declared).toBe(true); + } + ); + + it("does not treat other dependency fields as a direct declaration", async () => { + await writeManifest({ + optionalDependencies: { notcms: "1.0.0" }, + peerDependencies: { notcms: "1.0.0" }, + }); + + expect(inspectNotcmsDependency(dir).declared).toBe(false); + }); + + it("reports a direct dependency as unresolved before it is installed", async () => { + await writeManifest({ dependencies: { notcms: "file:../notcms" } }); + + expect(inspectNotcmsDependency(dir)).toEqual({ + declared: true, + resolved: false, + }); + }); + + it("resolves notcms from the target project's node_modules", async () => { + await writeManifest({ dependencies: { notcms: "file:../notcms" } }); + const packageDirectory = path.join(dir, "node_modules", "notcms"); + await fs.mkdir(packageDirectory, { recursive: true }); + await fs.writeFile( + path.join(packageDirectory, "package.json"), + JSON.stringify({ name: "notcms", main: "index.js" }) + ); + await fs.writeFile(path.join(packageDirectory, "index.js"), "export {};"); + + expect(inspectNotcmsDependency(dir)).toEqual({ + declared: true, + resolved: true, + }); + }); + + it("resolves Yarn PnP without requiring the CLI process to use the PnP loader", async () => { + const workspaceDir = path.join(dir, "apps", "site"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile( + path.join(workspaceDir, "package.json"), + JSON.stringify({ dependencies: { notcms: "workspace:^" } }) + ); + const pnpPath = path.join(dir, ".pnp.cjs"); + await fs.writeFile( + pnpPath, + `module.exports = { + resolveRequest(request, issuer, options) { + if (request !== "notcms") throw new Error("unexpected request"); + if (!issuer.endsWith(${JSON.stringify(path.join("apps", "site", "package.json"))})) throw new Error("unexpected issuer"); + if (options?.considerBuiltins !== false) throw new Error("unexpected options"); + return "/virtual/.yarn/cache/notcms.zip/node_modules/notcms/dist/index.js"; + }, + };` + ); + + expect( + inspectNotcmsDependency(workspaceDir, { targetPnpPath: pnpPath }) + ).toEqual({ + declared: true, + resolved: false, + verificationRequired: "target-pnp", + }); + expect( + inspectNotcmsDependency(workspaceDir, { + evaluateTargetPnp: true, + targetPnpPath: pnpPath, + }) + ).toEqual({ + declared: true, + resolved: true, + }); + }); + + it("does not accept a target PnP API that cannot resolve notcms", async () => { + await writeManifest({ dependencies: { notcms: "1.0.0" } }); + const pnpPath = path.join(dir, ".pnp.cjs"); + await fs.writeFile( + pnpPath, + `module.exports = { + resolveRequest() { + throw new Error("not installed"); + }, + };` + ); + + expect( + inspectNotcmsDependency(dir, { + evaluateTargetPnp: true, + targetPnpPath: pnpPath, + }) + ).toEqual({ + declared: true, + resolved: false, + }); + }); + + it("reloads the target PnP API after an install updates its resolution map", async () => { + await writeManifest({ dependencies: { notcms: "1.0.0" } }); + const pnpPath = path.join(dir, ".pnp.cjs"); + await fs.writeFile( + pnpPath, + `module.exports = { + resolveRequest() { + throw new Error("not installed yet"); + }, + };` + ); + expect( + inspectNotcmsDependency(dir, { + evaluateTargetPnp: true, + targetPnpPath: pnpPath, + }).resolved + ).toBe(false); + + await fs.writeFile( + pnpPath, + `module.exports = { + resolveRequest(request) { + if (request !== "notcms") throw new Error("unexpected request"); + return "/virtual/.yarn/cache/notcms.zip/node_modules/notcms/dist/index.js"; + }, + };` + ); + + expect( + inspectNotcmsDependency(dir, { + evaluateTargetPnp: true, + targetPnpPath: pnpPath, + }).resolved + ).toBe(true); + }); + + async function writeManifest(manifest: Record) { + await fs.writeFile( + path.join(dir, "package.json"), + JSON.stringify(manifest) + ); + } +}); diff --git a/packages/notcms/test/cli-dependency.spec.ts b/packages/notcms/test/cli-dependency.spec.ts new file mode 100644 index 0000000..d8fdddf --- /dev/null +++ b/packages/notcms/test/cli-dependency.spec.ts @@ -0,0 +1,718 @@ +import { EventEmitter } from "node:events"; +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const promptMocks = vi.hoisted(() => ({ + confirm: vi.fn(), +})); +const childProcessMocks = vi.hoisted(() => ({ + spawn: vi.fn(), +})); +const resolutionMocks = vi.hoisted(() => ({ + inspectNotcmsDependency: vi.fn(), +})); + +vi.mock("@inquirer/prompts", () => promptMocks); +vi.mock("node:child_process", () => childProcessMocks); +vi.mock("../src/cli/features/dependency-resolution.js", () => resolutionMocks); + +import { + type PackageManager, + createPackageManagerInvocation, + detectPackageManager, + ensureNotcmsDependency, +} from "../src/cli/features/dependency"; + +describe("createPackageManagerInvocation", () => { + it("uses cmd.exe safely for Windows command shims", () => { + expect( + createPackageManagerInvocation( + "pnpm", + ["add", "-w", "notcms"], + "win32", + "C:\\Windows\\System32\\cmd.exe" + ) + ).toEqual({ + executable: "C:\\Windows\\System32\\cmd.exe", + args: ["/d", "/s", "/c", '"pnpm.cmd add -w notcms"'], + windowsVerbatimArguments: true, + }); + }); + + it("rejects shell metacharacters before building a Windows command", () => { + expect(() => + createPackageManagerInvocation( + "npm", + ["install", "notcms&whoami"], + "win32", + "cmd.exe" + ) + ).toThrow("Unsafe package-manager argument for Windows."); + }); +}); + +describe("detectPackageManager", () => { + let dir: string; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(tmpdir(), "notcms-dependency-")); + await fs.writeFile( + path.join(dir, "package.json"), + JSON.stringify({ name: "notcms-dependency-fixture", private: true }) + ); + }); + + afterEach(async () => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + await fs.rm(dir, { recursive: true, force: true }); + }); + + it.each([ + ["pnpm@10.0.0", "pnpm", undefined], + ["npm@11.0.0", "npm", undefined], + ["yarn@4.0.0", "yarn", 4], + ["bun@1.2.0", "bun", undefined], + ])("detects %s from package.json", async (value, expected, major) => { + await writePackageJson({ packageManager: value }); + + expect(detectPackageManager({ cwd: dir, userAgent: "npm/11.0.0" })).toEqual( + { + status: "detected", + packageManager: expected, + ...(major === undefined ? {} : { packageManagerMajor: major }), + } + ); + }); + + it.each([ + ["pnpm-lock.yaml", "pnpm"], + ["package-lock.json", "npm"], + ["yarn.lock", "yarn"], + ["bun.lock", "bun"], + ])("detects %s", async (lockfile, expected) => { + await fs.writeFile(path.join(dir, lockfile), ""); + + expect(detectPackageManager({ cwd: dir, userAgent: "" })).toEqual({ + status: "detected", + packageManager: expected, + }); + }); + + it.each([ + ["pnpm/10.0.0 node/v22", "pnpm", undefined], + ["npm/11.0.0 node/v22", "npm", undefined], + ["yarn/4.0.0 node/v22", "yarn", 4], + ["bun/1.2.0", "bun", undefined], + ])("detects %s from the user agent", (userAgent, expected, major) => { + expect(detectPackageManager({ cwd: dir, userAgent })).toEqual({ + status: "detected", + packageManager: expected, + ...(major === undefined ? {} : { packageManagerMajor: major }), + }); + }); + + it("prefers packageManager over lockfiles and the user agent", async () => { + await writePackageJson({ packageManager: "yarn@4.0.0" }); + await fs.writeFile(path.join(dir, "pnpm-lock.yaml"), ""); + + expect(detectPackageManager({ cwd: dir, userAgent: "npm/11.0.0" })).toEqual( + { + status: "detected", + packageManager: "yarn", + packageManagerMajor: 4, + } + ); + }); + + it("uses a nearer lockfile instead of an ancestor packageManager", async () => { + await writePackageJson({ packageManager: "pnpm@10.0.0" }); + const childDir = path.join(dir, "app"); + await fs.mkdir(childDir); + await fs.writeFile(path.join(childDir, "package-lock.json"), ""); + + expect( + detectPackageManager({ cwd: childDir, userAgent: "pnpm/10.0.0" }) + ).toEqual({ + status: "detected", + packageManager: "npm", + }); + }); + + it("uses the user agent to disambiguate multiple lockfiles", async () => { + await fs.writeFile(path.join(dir, "pnpm-lock.yaml"), ""); + await fs.writeFile(path.join(dir, "yarn.lock"), ""); + + expect(detectPackageManager({ cwd: dir, userAgent: "yarn/4.0.0" })).toEqual( + { + status: "detected", + packageManager: "yarn", + packageManagerMajor: 4, + } + ); + }); + + it("requires manual resolution for conflicting lockfiles without a matching user agent", async () => { + await fs.writeFile(path.join(dir, "pnpm-lock.yaml"), ""); + await fs.writeFile(path.join(dir, "yarn.lock"), ""); + + expect(detectPackageManager({ cwd: dir, userAgent: "npm/11.0.0" })).toEqual( + expect.objectContaining({ + status: "manual", + reason: "ambiguous-lockfiles", + }) + ); + }); + + it("requires manual resolution for an invalid explicit packageManager", async () => { + await writePackageJson({ packageManager: "npm; touch notcms-owned" }); + + expect(detectPackageManager({ cwd: dir, userAgent: "npm/11.0.0" })).toEqual( + expect.objectContaining({ + status: "manual", + reason: "invalid-package-manager", + }) + ); + }); + + async function writePackageJson(value: Record) { + await fs.writeFile(path.join(dir, "package.json"), JSON.stringify(value)); + } +}); + +describe("ensureNotcmsDependency", () => { + let dir: string; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(tmpdir(), "notcms-dependency-")); + await fs.writeFile( + path.join(dir, "package.json"), + JSON.stringify({ name: "notcms-dependency-fixture", private: true }) + ); + resolutionMocks.inspectNotcmsDependency.mockReturnValue({ + declared: false, + resolved: false, + }); + }); + + afterEach(async () => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + await fs.rm(dir, { recursive: true, force: true }); + }); + + it("skips installation only when notcms is direct and resolvable", async () => { + resolutionMocks.inspectNotcmsDependency.mockReturnValue({ + declared: true, + resolved: true, + }); + + await expect( + ensureNotcmsDependency({ cwd: dir, interactive: true }) + ).resolves.toEqual({ status: "already-installed" }); + expect(promptMocks.confirm).not.toHaveBeenCalled(); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + }); + + it.each<{ + packageManager: PackageManager; + userAgent: string; + args: string[]; + command: string; + }>([ + { + packageManager: "pnpm", + userAgent: "pnpm/10.0.0", + args: ["add", "notcms"], + command: "pnpm add notcms", + }, + { + packageManager: "npm", + userAgent: "npm/11.0.0", + args: ["install", "notcms"], + command: "npm install notcms", + }, + { + packageManager: "yarn", + userAgent: "yarn/4.0.0", + args: ["add", "notcms"], + command: "yarn add notcms", + }, + { + packageManager: "bun", + userAgent: "bun/1.2.0", + args: ["add", "notcms"], + command: "bun add notcms", + }, + ])( + "adds with $packageManager using an argv-only child process", + async ({ packageManager, userAgent, args, command }) => { + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => closingChild(0)); + + await expect( + ensureNotcmsDependency({ cwd: dir, interactive: true, userAgent }) + ).resolves.toEqual({ status: "installed", packageManager, command }); + expect(promptMocks.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + default: true, + message: expect.stringContaining(command), + }) + ); + expectInstallSpawn(packageManager, args); + } + ); + + it("adds notcms when it resolves only transitively or through hoisting", async () => { + resolutionMocks.inspectNotcmsDependency.mockReturnValue({ + declared: false, + resolved: true, + }); + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => closingChild(0)); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "npm/11.0.0", + }) + ).resolves.toEqual({ + status: "installed", + packageManager: "npm", + command: "npm install notcms", + }); + expectInstallSpawn("npm", ["install", "notcms"]); + }); + + it("adds to a pnpm workspace root with the required workspace flag", async () => { + await fs.writeFile(path.join(dir, "pnpm-workspace.yaml"), "packages: []\n"); + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => closingChild(0)); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "pnpm/10.0.0", + }) + ).resolves.toEqual({ + status: "installed", + packageManager: "pnpm", + command: "pnpm add -w notcms", + }); + expectInstallSpawn("pnpm", ["add", "-w", "notcms"]); + }); + + it("adds to a Yarn Classic workspace root with its required root flag", async () => { + await fs.writeFile( + path.join(dir, "package.json"), + JSON.stringify({ + packageManager: "yarn@1.22.22", + private: true, + workspaces: ["packages/*"], + }) + ); + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => closingChild(0)); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "npm/11.0.0", + }) + ).resolves.toEqual({ + status: "installed", + packageManager: "yarn", + command: "yarn add -W notcms", + }); + expectInstallSpawn("yarn", ["add", "-W", "notcms"]); + }); + + it("infers Yarn Classic from a v1 lockfile at a workspace root", async () => { + await fs.writeFile( + path.join(dir, "package.json"), + JSON.stringify({ private: true, workspaces: ["packages/*"] }) + ); + await fs.writeFile(path.join(dir, "yarn.lock"), "# yarn lockfile v1\n"); + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => closingChild(0)); + + await expect( + ensureNotcmsDependency({ cwd: dir, interactive: true, userAgent: "" }) + ).resolves.toEqual({ + status: "installed", + packageManager: "yarn", + command: "yarn add -W notcms", + }); + expectInstallSpawn("yarn", ["add", "-W", "notcms"]); + }); + + it("uses the normal add command at a Yarn Berry workspace root", async () => { + await fs.writeFile( + path.join(dir, "package.json"), + JSON.stringify({ + packageManager: "yarn@4.9.2", + private: true, + workspaces: ["packages/*"], + }) + ); + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => closingChild(0)); + + await expect( + ensureNotcmsDependency({ cwd: dir, interactive: true, userAgent: "" }) + ).resolves.toEqual({ + status: "installed", + packageManager: "yarn", + command: "yarn add notcms", + }); + expectInstallSpawn("yarn", ["add", "notcms"]); + }); + + it("requires a Yarn version before changing an ambiguous workspace root", async () => { + await fs.writeFile( + path.join(dir, "package.json"), + JSON.stringify({ + packageManager: "yarn", + private: true, + workspaces: ["packages/*"], + }) + ); + + await expect( + ensureNotcmsDependency({ cwd: dir, interactive: true, userAgent: "" }) + ).resolves.toEqual({ + status: "manual", + reason: "invalid-package-manager", + error: expect.stringContaining("Yarn Classic or Yarn Berry"), + }); + expect(promptMocks.confirm).not.toHaveBeenCalled(); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + }); + + it("installs an existing direct dependency at a version-ambiguous Yarn root", async () => { + resolutionMocks.inspectNotcmsDependency.mockReturnValue({ + declared: true, + resolved: false, + }); + await fs.writeFile( + path.join(dir, "package.json"), + JSON.stringify({ + packageManager: "yarn", + private: true, + workspaces: ["packages/*"], + dependencies: { notcms: "file:../notcms" }, + }) + ); + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => closingChild(0)); + + await expect( + ensureNotcmsDependency({ cwd: dir, interactive: true, userAgent: "" }) + ).resolves.toEqual({ + status: "installed", + packageManager: "yarn", + command: "yarn install", + }); + expectInstallSpawn("yarn", ["install"]); + }); + + it("defaults a truly empty project to npm add", async () => { + await fs.rm(path.join(dir, "package.json")); + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => closingChild(0)); + + await expect( + ensureNotcmsDependency({ cwd: dir, interactive: true, userAgent: "" }) + ).resolves.toEqual({ + status: "installed", + packageManager: "npm", + command: "npm install notcms", + }); + expectInstallSpawn("npm", ["install", "notcms"]); + }); + + it.each<{ packageManager: PackageManager; userAgent: string }>([ + { packageManager: "pnpm", userAgent: "pnpm/10.0.0" }, + { packageManager: "npm", userAgent: "npm/11.0.0" }, + { packageManager: "yarn", userAgent: "yarn/4.0.0" }, + { packageManager: "bun", userAgent: "bun/1.2.0" }, + ])( + "runs $packageManager install when the direct dependency is unresolved", + async ({ packageManager, userAgent }) => { + resolutionMocks.inspectNotcmsDependency.mockReturnValue({ + declared: true, + resolved: false, + }); + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => closingChild(0)); + + await expect( + ensureNotcmsDependency({ cwd: dir, interactive: true, userAgent }) + ).resolves.toEqual({ + status: "installed", + packageManager, + command: `${packageManager} install`, + }); + expect(promptMocks.confirm).toHaveBeenCalledWith( + expect.objectContaining({ + default: true, + message: expect.stringContaining(`${packageManager} install`), + }) + ); + expectInstallSpawn(packageManager, ["install"]); + } + ); + + it("returns the exact manual command when installation is declined", async () => { + promptMocks.confirm.mockResolvedValue(false); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "pnpm/10.0.0", + }) + ).resolves.toEqual({ + status: "manual", + reason: "declined", + packageManager: "pnpm", + command: "pnpm add notcms", + }); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + }); + + it("describes a declared target PnP dependency as unverified before consent", async () => { + resolutionMocks.inspectNotcmsDependency.mockReturnValue({ + declared: true, + resolved: false, + verificationRequired: "target-pnp", + }); + promptMocks.confirm.mockResolvedValue(false); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "yarn/4.0.0", + }) + ).resolves.toEqual({ + status: "manual", + reason: "declined", + packageManager: "yarn", + command: "yarn install", + verificationRequired: "target-pnp", + }); + expect(promptMocks.confirm).toHaveBeenCalledWith({ + default: true, + message: + 'The notcms dependency is declared in a Yarn PnP project, but its resolution has not been verified. Run "yarn install" now, then verify it?', + }); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + }); + + it("returns the exact manual command when installation fails", async () => { + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => closingChild(7)); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "yarn/4.0.0", + }) + ).resolves.toEqual({ + status: "manual", + reason: "failed", + packageManager: "yarn", + command: "yarn add notcms", + error: "yarn install failed with exit code 7.", + }); + }); + + it("returns the manual command when a zero exit does not install notcms", async () => { + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => + closingChildWithoutInstall(0) + ); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "npm/11.0.0", + }) + ).resolves.toEqual({ + status: "manual", + reason: "failed", + packageManager: "npm", + command: "npm install notcms", + error: + "npm install notcms exited successfully, but notcms is still not a direct, resolvable project dependency.", + }); + expect(resolutionMocks.inspectNotcmsDependency).toHaveBeenCalledTimes(2); + expect(resolutionMocks.inspectNotcmsDependency).toHaveBeenNthCalledWith( + 1, + dir + ); + expect(resolutionMocks.inspectNotcmsDependency).toHaveBeenNthCalledWith( + 2, + dir, + {} + ); + }); + + it("does not prompt or spawn in a non-interactive session", async () => { + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: false, + userAgent: "bun/1.2.0", + }) + ).resolves.toEqual({ + status: "manual", + reason: "non-interactive", + packageManager: "bun", + command: "bun add notcms", + }); + expect(promptMocks.confirm).not.toHaveBeenCalled(); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + }); + + it("does not prompt or spawn in CI by default", async () => { + vi.stubEnv("CI", "true"); + + await expect( + ensureNotcmsDependency({ cwd: dir, userAgent: "npm/11.0.0" }) + ).resolves.toEqual({ + status: "manual", + reason: "non-interactive", + packageManager: "npm", + command: "npm install notcms", + }); + expect(promptMocks.confirm).not.toHaveBeenCalled(); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + }); + + it("returns the manual command when the package manager cannot start", async () => { + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => erroringChild()); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "pnpm/10.0.0", + }) + ).resolves.toEqual({ + status: "manual", + reason: "failed", + packageManager: "pnpm", + command: "pnpm add notcms", + error: "spawn pnpm ENOENT", + }); + }); + + it("does not prompt or spawn for an invalid explicit packageManager", async () => { + await fs.writeFile( + path.join(dir, "package.json"), + JSON.stringify({ packageManager: "npm; touch notcms-owned" }) + ); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "npm/11.0.0", + }) + ).resolves.toEqual( + expect.objectContaining({ + status: "manual", + reason: "invalid-package-manager", + }) + ); + expect(promptMocks.confirm).not.toHaveBeenCalled(); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + }); + + it("does not prompt or spawn for ambiguous lockfiles", async () => { + await fs.writeFile(path.join(dir, "pnpm-lock.yaml"), ""); + await fs.writeFile(path.join(dir, "yarn.lock"), ""); + + await expect( + ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "npm/11.0.0", + }) + ).resolves.toEqual( + expect.objectContaining({ + status: "manual", + reason: "ambiguous-lockfiles", + }) + ); + expect(promptMocks.confirm).not.toHaveBeenCalled(); + expect(childProcessMocks.spawn).not.toHaveBeenCalled(); + }); + + it("never passes a project-controlled user agent to a shell", async () => { + promptMocks.confirm.mockResolvedValue(true); + childProcessMocks.spawn.mockImplementation(() => closingChild(0)); + + await ensureNotcmsDependency({ + cwd: dir, + interactive: true, + userAgent: "unknown; touch notcms-owned/1.0.0", + }); + + expectInstallSpawn("npm", ["install", "notcms"]); + }); + + function closingChild(exitCode: number) { + const child = new EventEmitter(); + queueMicrotask(() => { + if (exitCode === 0) { + resolutionMocks.inspectNotcmsDependency.mockReturnValue({ + declared: true, + resolved: true, + }); + } + child.emit("close", exitCode, null); + }); + return child; + } + + function closingChildWithoutInstall(exitCode: number) { + const child = new EventEmitter(); + queueMicrotask(() => child.emit("close", exitCode, null)); + return child; + } + + function erroringChild() { + const child = new EventEmitter(); + queueMicrotask(() => child.emit("error", new Error("spawn pnpm ENOENT"))); + return child; + } + + function expectInstallSpawn( + packageManager: "pnpm" | "npm" | "yarn" | "bun", + args: string[] + ) { + const invocation = createPackageManagerInvocation(packageManager, args); + expect(childProcessMocks.spawn).toHaveBeenCalledWith( + invocation.executable, + invocation.args, + { + cwd: dir, + shell: false, + stdio: "inherit", + ...(invocation.windowsVerbatimArguments + ? { windowsVerbatimArguments: true } + : {}), + } + ); + } +}); diff --git a/packages/notcms/test/cli-init.spec.ts b/packages/notcms/test/cli-init.spec.ts new file mode 100644 index 0000000..15f6d35 --- /dev/null +++ b/packages/notcms/test/cli-init.spec.ts @@ -0,0 +1,272 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const promptMocks = vi.hoisted(() => ({ + confirm: vi.fn(), + input: vi.fn(), +})); +const loginMocks = vi.hoisted(() => ({ + getCredentialsFromEnv: vi.fn(), + loginViaBrowser: vi.fn(), + saveCredentials: vi.fn(), +})); +const pullMocks = vi.hoisted(() => ({ + pullSchema: vi.fn(), +})); +const dependencyMocks = vi.hoisted(() => ({ + ensureNotcmsDependency: vi.fn(), +})); + +vi.mock("@inquirer/prompts", () => promptMocks); +vi.mock("../src/cli/features/dependency.js", () => dependencyMocks); +vi.mock("../src/cli/features/login.js", () => loginMocks); +vi.mock("../src/cli/features/pull.js", () => pullMocks); + +import { init } from "../src/cli/commands"; + +describe("init command", () => { + let dir: string; + let cwdSpy: ReturnType; + let logSpy: ReturnType; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(tmpdir(), "notcms-init-")); + cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(dir); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + promptMocks.input.mockResolvedValue("src/notcms/schema.ts"); + dependencyMocks.ensureNotcmsDependency.mockResolvedValue({ + status: "already-installed", + }); + }); + + afterEach(async () => { + cwdSpy.mockRestore(); + logSpy.mockRestore(); + vi.clearAllMocks(); + vi.unstubAllEnvs(); + await fs.rm(dir, { recursive: true, force: true }); + }); + + it("automatically pulls with existing credentials and prints a safe query", async () => { + const credentials = { + secretKey: "ncsec_existing", + workspaceId: "ws_existing", + }; + loginMocks.getCredentialsFromEnv.mockReturnValue(credentials); + pullMocks.pullSchema.mockResolvedValue({ + status: "written", + schemaPath: "src/notcms/schema.ts", + firstDatabaseName: "Blog posts", + }); + + await init(); + + expect(promptMocks.confirm).not.toHaveBeenCalled(); + expect(loginMocks.loginViaBrowser).not.toHaveBeenCalled(); + expect(dependencyMocks.ensureNotcmsDependency).toHaveBeenCalledOnce(); + expect(pullMocks.pullSchema).toHaveBeenCalledWith({ credentials }); + expect(output()).toContain('nc.query["Blog posts"].list()'); + }); + + it("passes browser login credentials directly to pull and supports an empty database name", async () => { + vi.stubEnv("NOTCMS_SECRET_KEY", undefined); + vi.stubEnv("NOTCMS_WORKSPACE_ID", undefined); + const credentials = { + secretKey: "ncsec_browser", + workspaceId: "ws_browser", + }; + loginMocks.getCredentialsFromEnv.mockReturnValue(null); + promptMocks.confirm.mockResolvedValue(true); + loginMocks.loginViaBrowser.mockResolvedValue(credentials); + loginMocks.saveCredentials.mockResolvedValue(path.join(dir, ".env.local")); + pullMocks.pullSchema.mockResolvedValue({ + status: "written", + schemaPath: "src/notcms/schema.ts", + firstDatabaseName: "", + }); + + await init(); + + expect(loginMocks.saveCredentials).toHaveBeenCalledWith( + credentials, + undefined + ); + expect(pullMocks.pullSchema).toHaveBeenCalledWith({ credentials }); + expect(output()).toContain('nc.query[""].list()'); + expect(output()).not.toContain("No databases are available"); + expect(output()).not.toContain("Next, pull your schema"); + }); + + it("does not log in or pull when browser login is declined", async () => { + loginMocks.getCredentialsFromEnv.mockReturnValue(null); + promptMocks.confirm.mockResolvedValue(false); + + await init(); + + expect(loginMocks.loginViaBrowser).not.toHaveBeenCalled(); + expect(dependencyMocks.ensureNotcmsDependency).not.toHaveBeenCalled(); + expect(pullMocks.pullSchema).not.toHaveBeenCalled(); + expect(output()).toContain("npx notcms login"); + await expect( + fs.readFile(path.join(dir, "notcms.config.json"), "utf-8") + ).resolves.toContain('"schema": "src/notcms/schema.ts"'); + }); + + it("prints setup guidance only when the schema has no database keys", async () => { + loginMocks.getCredentialsFromEnv.mockReturnValue({ + secretKey: "ncsec_existing", + workspaceId: "ws_existing", + }); + pullMocks.pullSchema.mockResolvedValue({ + status: "written", + schemaPath: "src/notcms/schema.ts", + firstDatabaseName: null, + }); + + await init(); + + expect(output()).toContain("No databases are available"); + expect(output()).not.toContain("nc.query["); + }); + + it("prints a declined dependency install command after schema generation", async () => { + loginMocks.getCredentialsFromEnv.mockReturnValue({ + secretKey: "ncsec_existing", + workspaceId: "ws_existing", + }); + dependencyMocks.ensureNotcmsDependency.mockResolvedValue({ + status: "manual", + reason: "declined", + packageManager: "pnpm", + command: "pnpm add notcms", + }); + pullMocks.pullSchema.mockResolvedValue({ + status: "written", + schemaPath: "src/notcms/schema.ts", + firstDatabaseName: "Blog", + }); + + await init(); + + expect(pullMocks.pullSchema).toHaveBeenCalled(); + expect(output()).toContain('nc.query["Blog"].list()'); + const lastLog = logSpy.mock.calls[logSpy.mock.calls.length - 1]?.[0]; + expect(String(lastLog)).toContain("$ pnpm add notcms"); + }); + + it("prints a failed dependency install command and error after schema generation", async () => { + loginMocks.getCredentialsFromEnv.mockReturnValue({ + secretKey: "ncsec_existing", + workspaceId: "ws_existing", + }); + dependencyMocks.ensureNotcmsDependency.mockResolvedValue({ + status: "manual", + reason: "failed", + packageManager: "npm", + command: "npm install notcms", + error: "npm install failed with exit code 1.", + }); + pullMocks.pullSchema.mockResolvedValue({ + status: "written", + schemaPath: "src/notcms/schema.ts", + firstDatabaseName: "Blog", + }); + + await init(); + + expect(output()).toContain('nc.query["Blog"].list()'); + const lastLog = logSpy.mock.calls[logSpy.mock.calls.length - 1]?.[0]; + expect(String(lastLog)).toContain("npm install failed with exit code 1."); + expect(String(lastLog)).toContain("$ npm install notcms"); + }); + + it("reports an unverified Yarn PnP resolution without claiming the package is missing", async () => { + loginMocks.getCredentialsFromEnv.mockReturnValue({ + secretKey: "ncsec_existing", + workspaceId: "ws_existing", + }); + dependencyMocks.ensureNotcmsDependency.mockResolvedValue({ + status: "manual", + reason: "non-interactive", + packageManager: "yarn", + command: "yarn install", + verificationRequired: "target-pnp", + }); + pullMocks.pullSchema.mockResolvedValue({ + status: "written", + schemaPath: "src/notcms/schema.ts", + firstDatabaseName: "Blog", + }); + + await init(); + + const lastLog = logSpy.mock.calls[logSpy.mock.calls.length - 1]?.[0]; + expect(String(lastLog)).toContain("Yarn PnP project's notcms resolution"); + expect(String(lastLog)).toContain("has not been verified"); + expect(String(lastLog)).not.toContain("project still needs the notcms"); + expect(String(lastLog)).toContain("$ yarn install"); + }); + + it("prints package-manager configuration guidance after schema generation", async () => { + loginMocks.getCredentialsFromEnv.mockReturnValue({ + secretKey: "ncsec_existing", + workspaceId: "ws_existing", + }); + dependencyMocks.ensureNotcmsDependency.mockResolvedValue({ + status: "manual", + reason: "ambiguous-lockfiles", + error: "Multiple package-manager lockfiles were found.", + }); + pullMocks.pullSchema.mockResolvedValue({ + status: "written", + schemaPath: "src/notcms/schema.ts", + firstDatabaseName: "Blog", + }); + + await init(); + + expect(output()).toContain('nc.query["Blog"].list()'); + const lastLog = logSpy.mock.calls[logSpy.mock.calls.length - 1]?.[0]; + expect(String(lastLog)).toContain( + "Multiple package-manager lockfiles were found." + ); + expect(String(lastLog)).toContain("then install notcms as a direct"); + expect(String(lastLog)).toContain("project dependency"); + }); + + it.each([ + { firstDatabaseName: "Blog", finalGuidance: 'nc.query["Blog"].list()' }, + { + firstDatabaseName: null, + finalGuidance: "No databases are available", + }, + ])( + "prints $finalGuidance last in a Next.js project", + async ({ firstDatabaseName, finalGuidance }) => { + await fs.writeFile( + path.join(dir, "next.config.ts"), + "export default {};" + ); + loginMocks.getCredentialsFromEnv.mockReturnValue({ + secretKey: "ncsec_existing", + workspaceId: "ws_existing", + }); + pullMocks.pullSchema.mockResolvedValue({ + status: "written", + schemaPath: "src/notcms/schema.ts", + firstDatabaseName, + }); + + await init(); + + expect(output()).toContain("Next.js project detected"); + const lastLog = logSpy.mock.calls[logSpy.mock.calls.length - 1]?.[0]; + expect(String(lastLog)).toContain(finalGuidance); + } + ); + + function output(): string { + return logSpy.mock.calls.map(([value]) => String(value)).join("\n"); + } +}); diff --git a/packages/notcms/test/cli-pull.spec.ts b/packages/notcms/test/cli-pull.spec.ts new file mode 100644 index 0000000..e781198 --- /dev/null +++ b/packages/notcms/test/cli-pull.spec.ts @@ -0,0 +1,250 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { pullSchema } from "../src/cli/features/pull"; +import type { Schema } from "../src/types"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const credentials = { + secretKey: "ncsec_test", + workspaceId: "ws_test", +}; + +describe("pullSchema", () => { + let dir: string; + let cwdSpy: ReturnType; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(packageRoot, ".notcms-pull-")); + cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(dir); + }); + + afterEach(async () => { + cwdSpy.mockRestore(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + await fs.rm(dir, { recursive: true, force: true }); + }); + + it("writes the configured schema and returns its first database", async () => { + const schema = { + "Blog posts": { + id: "db_blog", + properties: { Slug: "rich_text" }, + }, + } satisfies Schema; + const fetchMock = stubSchema(schema); + await writeConfig("schema.ts"); + + await expect(pullSchema({ credentials })).resolves.toEqual({ + status: "written", + schemaPath: "schema.ts", + firstDatabaseName: "Blog posts", + }); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/ws/ws_test/schema"), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer ncsec_test", + }), + }) + ); + await expect( + fs.readFile(path.join(dir, "schema.ts"), "utf-8") + ).resolves.toBe(`import { Client } from "notcms"; +import type { Schema } from "notcms"; + +export const schema = { + "Blog posts": { + "id": "db_blog", + "properties": { + "Slug": "rich_text" + } + } +} satisfies Schema; +export const nc = new Client({ schema });`); + }); + + it("returns up-to-date when check mode matches the generated schema", async () => { + const schema = { + Blog: { id: "db_blog", properties: { Title: "title" } }, + } satisfies Schema; + stubSchema(schema); + await writeConfig("src/notcms/schema.ts"); + await pullSchema({ credentials }); + + await expect(pullSchema({ check: true, credentials })).resolves.toEqual({ + status: "up-to-date", + schemaPath: "src/notcms/schema.ts", + firstDatabaseName: "Blog", + }); + }); + + it("uses the server-selected onboarding database for query guidance", async () => { + const schema = { + "Older Blog": { id: "db_old", properties: { Title: "title" } }, + Blog: { id: "db_selected", properties: { Title: "title" } }, + } satisfies Schema; + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ onboardingDatabaseName: "Blog", schema }), + { status: 200 } + ) + ) + ); + await writeConfig("schema.ts"); + + await expect(pullSchema({ credentials })).resolves.toMatchObject({ + firstDatabaseName: "Blog", + status: "written", + }); + }); + + it("does not invent guidance when the server cannot persist a target", async () => { + const schema = { + Blog: { id: "db_unresolved", properties: { Title: "title" } }, + } satisfies Schema; + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ onboardingDatabaseName: null, schema }), + { status: 200 } + ) + ) + ); + await writeConfig("schema.ts"); + + await expect(pullSchema({ credentials })).resolves.toMatchObject({ + firstDatabaseName: null, + status: "written", + }); + }); + + it("reports a missing schema without writing in check mode", async () => { + stubSchema({ + Blog: { id: "db_blog", properties: { Title: "title" } }, + }); + await writeConfig("src/notcms/schema.ts"); + + await expect(pullSchema({ check: true, credentials })).resolves.toEqual({ + status: "stale", + schemaPath: "src/notcms/schema.ts", + reason: "missing", + }); + await expect( + fs.readFile(path.join(dir, "src/notcms/schema.ts"), "utf-8") + ).rejects.toThrow(); + }); + + it("reports an out-of-date schema without replacing it in check mode", async () => { + stubSchema({ + Blog: { id: "db_blog", properties: { Title: "title" } }, + }); + await writeConfig("schema.ts"); + await fs.writeFile(path.join(dir, "schema.ts"), "existing content"); + + await expect(pullSchema({ check: true, credentials })).resolves.toEqual({ + status: "stale", + schemaPath: "schema.ts", + reason: "out-of-date", + }); + await expect( + fs.readFile(path.join(dir, "schema.ts"), "utf-8") + ).resolves.toBe("existing content"); + }); + + it("writes an empty schema without inventing a query target", async () => { + stubSchema({}); + await writeConfig("src/notcms/schema.ts"); + + await expect(pullSchema({ credentials })).resolves.toEqual({ + status: "written", + schemaPath: "src/notcms/schema.ts", + firstDatabaseName: null, + }); + }); + + it("imports and queries a generated schema with prototype-like database names", async () => { + const schema = { + ["__proto__"]: { + id: "db_proto", + properties: { ["__proto__"]: "rich_text" }, + }, + constructor: { id: "db_constructor", properties: {} }, + prototype: { id: "db_prototype", properties: {} }, + } satisfies Schema; + const pages = [{ id: "page_1", title: "Safe", properties: {} }]; + const fetchMock = vi.fn((url: string | URL | Request) => { + const href = String(url); + if (href.endsWith("/schema")) { + return Promise.resolve( + new Response(JSON.stringify({ schema }), { status: 200 }) + ); + } + return Promise.resolve( + new Response(JSON.stringify({ data: pages }), { status: 200 }) + ); + }); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("NOTCMS_SECRET_KEY", "ncsec_generated"); + vi.stubEnv("NOTCMS_WORKSPACE_ID", "ws_generated"); + await writeConfig("generated-schema.ts"); + + await pullSchema({ credentials }); + const generatedPath = path.join(dir, "generated-schema.ts"); + const generated = await import(pathToFileURL(generatedPath).href); + + expect(Object.keys(generated.schema)).toEqual([ + "__proto__", + "constructor", + "prototype", + ]); + expect( + Object.prototype.hasOwnProperty.call( + generated.schema["__proto__"].properties, + "__proto__" + ) + ).toBe(true); + for (const databaseName of Object.keys(schema)) { + expect( + Object.prototype.hasOwnProperty.call(generated.nc.query, databaseName) + ).toBe(true); + expect(typeof generated.nc.query[databaseName].list).toBe("function"); + } + + const [data, error] = await generated.nc.query["__proto__"].list(); + + expect(error).toBeNull(); + expect(data).toEqual(pages); + expect(fetchMock).toHaveBeenLastCalledWith( + "https://api.notcms.com/v1/ws/ws_generated/db/db_proto/pages", + expect.objectContaining({ method: "GET" }) + ); + }); + + function stubSchema(schema: Schema) { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ schema }), { + status: 200, + }) + ) + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + async function writeConfig(schemaPath: string) { + await fs.writeFile( + path.join(dir, "notcms.config.json"), + JSON.stringify({ schema: schemaPath }) + ); + } +}); diff --git a/packages/notcms/test/cli-schema.spec.ts b/packages/notcms/test/cli-schema.spec.ts index 8d31f40..0b42398 100644 --- a/packages/notcms/test/cli-schema.spec.ts +++ b/packages/notcms/test/cli-schema.spec.ts @@ -1,4 +1,4 @@ -import { fetchSchema } from "../src/cli/features/schema"; +import { fetchSchema, fetchSchemaResponse } from "../src/cli/features/schema"; import { PROPERTY_TYPES } from "../src/types"; describe("fetchSchema", () => { @@ -46,6 +46,55 @@ describe("fetchSchema", () => { ); }); + it("returns the server-selected onboarding database metadata", async () => { + stubCredentials(); + const schema = { + Blog: { id: "db_1", properties: { slug: "rich_text" } }, + }; + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ onboardingDatabaseName: "Blog", schema }), + { status: 200 } + ) + ) + ); + + await expect(fetchSchemaResponse()).resolves.toEqual({ + onboardingDatabaseName: "Blog", + schema, + }); + }); + + it("uses explicitly supplied credentials when the environment is empty", async () => { + vi.stubEnv("NOTCMS_SECRET_KEY", undefined); + vi.stubEnv("NOTCMS_WORKSPACE_ID", undefined); + const schema = { + blog: { id: "db_1", properties: { slug: "rich_text" } }, + }; + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ schema }), { status: 200 }) + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + fetchSchema({ secretKey: "ncsec_login", workspaceId: "ws_login" }) + ).resolves.toEqual(schema); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.notcms.com/v1/ws/ws_login/schema", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer ncsec_login", + }), + }) + ); + }); + it("accepts every supported property type", async () => { stubCredentials(); const properties = Object.fromEntries( diff --git a/packages/notcms/vite.config.ts b/packages/notcms/vite.config.ts index 44800d7..f60c024 100644 --- a/packages/notcms/vite.config.ts +++ b/packages/notcms/vite.config.ts @@ -1,6 +1,14 @@ +import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + // Generated schema modules import the package by its public name. Resolve + // that self-reference to source so clean test runs do not depend on dist/. + alias: { + notcms: fileURLToPath(new URL("./src/index.ts", import.meta.url)), + }, + }, test: { globals: true, environment: "node",