diff --git a/README.md b/README.md index e78cd60..b32b2e9 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Run `tigris help` to see all available commands, or `tigris help` for | `tigris objects` (o) | Low-level object operations for listing, downloading, uploading, and deleting individual objects in a bucket | | `tigris access-keys` (keys) | Create, list, inspect, delete, and assign roles to access keys. Access keys are credentials used for programmatic API access | | `tigris iam` | Identity and Access Management - manage policies, users, and permissions | +| `tigris project` | Project scaffolding helpers for getting started with Tigris | --- @@ -1448,6 +1449,38 @@ tigris iam users remove user@example.com --yes tigris iam users remove user@example.com,user@example.net --yes ``` +### `tigris project` + +Project scaffolding helpers for getting started with Tigris + +| Command | Description | +|---------|-------------| +| `tigris project setup` | Set up a Tigris project — create a bucket, an Editor-scoped access key, and write a .env for the @tigrisdata/storage package | + +#### `tigris project setup` + +Set up a Tigris project — create a bucket, an Editor-scoped access key, and write a .env for the @tigrisdata/storage package + +``` +tigris project setup [flags] +``` + +| Flag | Description | +|------|-------------| +| `-b, --bucket` | Name of the bucket to create (default: a randomly generated name) | +| `-a, --access` | Access level for the bucket (default: private) | +| `-l, --locations` | Location(s) for the bucket, comma-separated (default: global) | +| `--env-file` | Path to the .env file to write (default: .env) | +| `--force` | Overwrite the .env file if it already exists | +| `--skip-agent-setup` | Skip installing Tigris agent skills | +| `--skip-install` | Skip installing the @tigrisdata/storage and @tigrisdata/agent-kit npm packages | + +**Examples:** +```bash +tigris project setup +tigris project setup --bucket my-app --access public +``` + ## License MIT diff --git a/package-lock.json b/package-lock.json index 00c9570..18eb5cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "enquirer": "^2.4.1", "jose": "^6.2.3", "open": "^11.0.0", + "unique-names-generator": "^4.7.1", "yaml": "^2.8.3" }, "bin": { @@ -11233,6 +11234,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unique-names-generator": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.7.1.tgz", + "integrity": "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/unique-string": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", diff --git a/package.json b/package.json index 7bf6eba..933f28f 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "enquirer": "^2.4.1", "jose": "^6.2.3", "open": "^11.0.0", + "unique-names-generator": "^4.7.1", "yaml": "^2.8.3" }, "devDependencies": { diff --git a/src/lib/project/agent-setup.ts b/src/lib/project/agent-setup.ts new file mode 100644 index 0000000..b1b8471 --- /dev/null +++ b/src/lib/project/agent-setup.ts @@ -0,0 +1,149 @@ +/** + * Agent skill installation for `tigris project setup`. + * + * Fetches the skills repo tarball and installs the Tigris storage skills into + * the project at .agents/skills, with per-skill symlinks from .claude/skills so + * Claude Code (and other skill-aware agents) pick them up. We intentionally do + * NOT install the Claude Code plugin from here: that mutates global config and + * can't take effect inside the same Claude session that launched setup. + * + * Everything here is best-effort: failures are reported but never abort the + * surrounding project setup. + */ + +import { + cp, + lstat, + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; + +import { run } from './exec.js'; + +const SKILLS_TARBALL_URL = + 'https://codeload.github.com/tigrisdata/skills/tar.gz/refs/heads/main'; +// Top-level directory inside the tarball (GitHub names it -). +const SKILLS_TARBALL_ROOT = 'skills-main'; + +/** + * Tigris storage-relevant skills to install. Generic skills in the repo + * (conventional-commits, go-table-driven-tests, language SDKs, …) are skipped. + */ +export const STORAGE_SKILLS = [ + 'file-storage', + 'tigris-agent-kit', + 'tigris-backup-export', + 'tigris-bucket-management', + 'tigris-egress-optimizer', + 'tigris-image-optimization', + 'tigris-lifecycle-management', + 'tigris-object-operations', +]; + +export type AgentSetupResult = { + mode: 'skills'; + installed: string[]; + error?: string; +}; + +/** lstat the path, returning null if it doesn't exist. */ +async function lstatOrNull(path: string) { + try { + return await lstat(path); + } catch { + return null; + } +} + +/** + * Copy the allowlisted storage skills from an extracted skills directory into + * the project's .agents/skills, then create per-skill symlinks under + * .claude/skills (.claude/skills/ -> ../../.agents/skills/). + * + * Returns the names of the skills that were installed. An existing real (non- + * symlink) entry in .claude/skills is left untouched rather than clobbered. + */ +export async function installSkillsFromDir( + sourceSkillsDir: string, + projectDir: string +): Promise { + const agentsSkillsDir = join(projectDir, '.agents', 'skills'); + const claudeSkillsDir = join(projectDir, '.claude', 'skills'); + await mkdir(agentsSkillsDir, { recursive: true }); + await mkdir(claudeSkillsDir, { recursive: true }); + + const installed: string[] = []; + + for (const name of STORAGE_SKILLS) { + const src = join(sourceSkillsDir, name); + const srcStat = await lstatOrNull(src); + if (!srcStat?.isDirectory()) continue; + + // Refresh the canonical copy under .agents/skills. + const dest = join(agentsSkillsDir, name); + await rm(dest, { recursive: true, force: true }); + await cp(src, dest, { recursive: true }); + installed.push(name); + + // Link it into .claude/skills with a relative target. + const link = join(claudeSkillsDir, name); + const existing = await lstatOrNull(link); + if (existing?.isSymbolicLink()) { + await rm(link); + } else if (existing) { + // A real directory/file lives here — don't clobber it. + continue; + } + await symlink(relative(claudeSkillsDir, dest), link); + } + + return installed; +} + +/** + * Download and extract the skills tarball, then install storage skills into the + * project. Uses the system `tar` to unpack. + */ +async function installSkillsFromTarball(projectDir: string): Promise { + const tmp = await mkdtemp(join(tmpdir(), 'tigris-skills-')); + try { + const res = await fetch(SKILLS_TARBALL_URL); + if (!res.ok) { + throw new Error(`Failed to download skills tarball (HTTP ${res.status})`); + } + const tarPath = join(tmp, 'skills.tar.gz'); + await writeFile(tarPath, Buffer.from(await res.arrayBuffer())); + await run('tar', ['-xzf', tarPath, '-C', tmp]); + + const sourceSkillsDir = join(tmp, SKILLS_TARBALL_ROOT, 'skills'); + if (!(await lstatOrNull(sourceSkillsDir))) { + throw new Error('Unexpected skills tarball layout'); + } + return await installSkillsFromDir(sourceSkillsDir, projectDir); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +} + +/** + * Install Tigris skills for the project. Best-effort: any failure is captured + * in the returned result rather than thrown. + */ +export async function setupAgentResources( + projectDir: string, + options: { quiet?: boolean } = {} +): Promise { + void options; // reserved for future flags; no per-mode behavior today + try { + const installed = await installSkillsFromTarball(projectDir); + return { mode: 'skills', installed }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { mode: 'skills', installed: [], error: message }; + } +} diff --git a/src/lib/project/exec.ts b/src/lib/project/exec.ts new file mode 100644 index 0000000..50b7550 --- /dev/null +++ b/src/lib/project/exec.ts @@ -0,0 +1,35 @@ +/** + * Small async process runner shared by the `tigris project setup` helpers. + */ + +import { spawn } from 'node:child_process'; + +export const COMMAND_TIMEOUT_MS = 120_000; + +/** + * Run a command to completion without blocking the event loop. Rejects on a + * non-zero exit, spawn error (e.g. command not found), or timeout. + */ +export function run( + command: string, + args: string[], + options: { stdio?: 'inherit' | 'ignore'; cwd?: string } = {} +): Promise { + const { stdio = 'ignore', cwd } = options; + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio, cwd }); + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`${command} timed out`)); + }, COMMAND_TIMEOUT_MS); + child.once('error', (err) => { + clearTimeout(timer); + reject(err); + }); + child.once('close', (code) => { + clearTimeout(timer); + if (code === 0) resolve(); + else reject(new Error(`${command} exited with code ${code}`)); + }); + }); +} diff --git a/src/lib/project/packages.ts b/src/lib/project/packages.ts new file mode 100644 index 0000000..61e28a2 --- /dev/null +++ b/src/lib/project/packages.ts @@ -0,0 +1,41 @@ +/** + * NPM package installation for `tigris project setup`. + * + * Installs the runtime packages a freshly set-up Tigris project needs: + * `@tigrisdata/storage` (the storage SDK the generated .env targets) and + * `@tigrisdata/agent-kit`. Best-effort: a missing `npm`, no package.json, or a + * failed install is reported but never aborts the surrounding project setup. + */ + +import { run } from './exec.js'; + +/** Packages installed into the project by default. */ +export const PROJECT_PACKAGES = [ + '@tigrisdata/storage', + '@tigrisdata/agent-kit', +]; + +export type PackageInstallResult = { + installed: string[]; + error?: string; +}; + +/** + * Run `npm install ` in the project directory. Any failure is + * captured in the returned result rather than thrown. + */ +export async function installProjectPackages( + projectDir: string, + options: { quiet?: boolean } = {} +): Promise { + try { + await run('npm', ['install', ...PROJECT_PACKAGES], { + cwd: projectDir, + stdio: options.quiet ? 'ignore' : 'inherit', + }); + return { installed: [...PROJECT_PACKAGES] }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { installed: [], error: message }; + } +} diff --git a/src/lib/project/setup.ts b/src/lib/project/setup.ts new file mode 100644 index 0000000..bfeba9b --- /dev/null +++ b/src/lib/project/setup.ts @@ -0,0 +1,207 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +import { getIAMConfig } from '@auth/iam.js'; +import { + getStorageConfig, + getTigrisConfig, + resolveAuthMethod, +} from '@auth/provider.js'; +import { assignBucketRoles, createAccessKey } from '@tigrisdata/iam'; +import type { BucketLocations } from '@tigrisdata/storage'; +import { createBucket, removeBucket } from '@tigrisdata/storage'; +import { failWithError, printNextActions } from '@utils/exit.js'; +import { parseLocations } from '@utils/locations.js'; +import { msg, printStart, printSuccess } from '@utils/messages.js'; +import { getFormat, getOption } from '@utils/options.js'; +import { + adjectives, + animals, + colors, + uniqueNamesGenerator, +} from 'unique-names-generator'; + +import { oauth } from '../login/oauth.js'; +import { setupAgentResources } from './agent-setup.js'; +import { installProjectPackages } from './packages.js'; + +const context = msg('project', 'setup'); + +export default async function setup(options: Record) { + printStart(context); + + const format = getFormat(options); + + const bucketOverride = getOption(options, ['bucket', 'b']); + const access = getOption(options, ['access', 'a']) ?? 'private'; + const locations = getOption(options, ['locations', 'l']) ?? 'global'; + const envFileOpt = + getOption(options, ['env-file', 'envFile']) ?? '.env'; + const force = getOption(options, ['force']) === true; + const skipAgentSetup = + getOption(options, ['skip-agent-setup', 'skipAgentSetup']) === + true; + const skipInstall = + getOption(options, ['skip-install', 'skipInstall']) === true; + + if (access !== 'private' && access !== 'public') { + failWithError( + context, + `Invalid access level "${access}". Use private or public.` + ); + } + + // Validate locations early so we fail before creating anything. + let parsedLocations: BucketLocations; + try { + parsedLocations = parseLocations(locations); + } catch (err) { + return failWithError(context, err); + } + + // Detect an existing .env early — if the project is already set up, abort + // before touching the account. + const envPath = resolve(process.cwd(), envFileOpt); + if (existsSync(envPath) && !force) { + failWithError( + context, + `A .env file already exists at ${envPath}.\nThis project looks already set up — use --force to overwrite it.` + ); + } + + // Ensure the user is authenticated. When no auth is configured, pop the + // browser via the OAuth device flow. + const method = await resolveAuthMethod(); + if (method.type === 'none') { + await oauth(); + } + + const bucketName = + bucketOverride ?? + uniqueNamesGenerator({ + dictionaries: [colors, adjectives, animals], + separator: '-', + length: 3, + }); + + // 1. Create the bucket. + const { error: bucketError } = await createBucket(bucketName, { + access, + locations: parsedLocations, + config: await getStorageConfig(), + }); + if (bucketError) { + failWithError(context, bucketError); + } + + // From here on, roll back the bucket if a later step fails so a retry isn't + // blocked by a half-created project. + const rollbackBucket = async () => { + try { + await removeBucket(bucketName, { + config: await getStorageConfig(), + force: true, + }); + } catch { + // best effort — surface the original error instead + } + }; + + // 2. Create an access key scoped to this project. + const { data: keyData, error: keyError } = await createAccessKey( + `${bucketName}-key`, + { config: await getIAMConfig(context) } + ); + if (keyError || !keyData) { + await rollbackBucket(); + failWithError(context, keyError ?? 'Failed to create access key'); + } + + // 3. Grant the key Editor access to the bucket. + const { error: assignError } = await assignBucketRoles( + keyData.id, + [{ bucket: bucketName, role: 'Editor' }], + { config: await getIAMConfig(context) } + ); + if (assignError) { + await rollbackBucket(); + failWithError(context, assignError); + } + + // 4. Write the .env for @tigrisdata/storage. + const endpoint = getTigrisConfig().endpoint; + const envContents = + [ + '# Generated by `tigris project setup`', + `TIGRIS_STORAGE_ACCESS_KEY_ID=${keyData.id}`, + `TIGRIS_STORAGE_SECRET_ACCESS_KEY=${keyData.secret}`, + `TIGRIS_STORAGE_ENDPOINT=${endpoint}`, + `TIGRIS_STORAGE_BUCKET=${bucketName}`, + ].join('\n') + '\n'; + + try { + mkdirSync(dirname(envPath), { recursive: true }); + writeFileSync(envPath, envContents, { mode: 0o600 }); + } catch (err) { + await rollbackBucket(); + return failWithError(context, err); + } + + const isJson = format === 'json'; + + // 5. Install the project's runtime npm packages (best-effort, non-fatal). + const packages = skipInstall + ? null + : await installProjectPackages(process.cwd(), { quiet: isJson }); + + // 6. Install agent resources for the project (best-effort, non-fatal). + const agent = skipAgentSetup + ? null + : await setupAgentResources(process.cwd(), { quiet: isJson }); + + if (isJson) { + console.log( + JSON.stringify({ + action: 'created', + bucket: bucketName, + accessKeyId: keyData.id, + envFile: envPath, + ...(packages ? { packages } : {}), + ...(agent ? { agentSetup: agent } : {}), + }) + ); + } else { + console.log(` Bucket: ${bucketName}`); + console.log(` Access Key ID: ${keyData.id}`); + console.log(` Env file: ${envPath}`); + console.log(''); + console.log( + ' The secret access key was written to the .env file and will not be shown again.' + ); + console.log(''); + if (packages) { + console.log(''); + if (packages.error) { + console.log(` Package install skipped: ${packages.error}`); + } else if (packages.installed.length > 0) { + console.log(` Installed ${packages.installed.join(', ')}.`); + } + } + if (agent) { + console.log(''); + if (agent.error) { + console.log(` Agent skill install skipped: ${agent.error}`); + } else if (agent.installed.length > 0) { + console.log( + ` Installed ${agent.installed.length} Tigris skill(s) into .agents/skills (linked from .claude/skills).` + ); + console.log( + ' Please read the Tigris skills in .agents/skills to help the user decide how to integrate object storage into their application' + ); + } + } + } + + printSuccess(context, { name: bucketName }); + printNextActions(context, { name: bucketName }); +} diff --git a/src/specs.yaml b/src/specs.yaml index f4a42a6..d726fad 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -2064,3 +2064,50 @@ commands: - name: force type: flag description: Skip confirmation prompts (alias for --yes) + + ######################### + # Project + ######################### + - name: project + description: Project scaffolding helpers for getting started with Tigris + examples: + - "tigris project setup" + commands: + - name: setup + description: Set up a Tigris project — create a bucket, an Editor-scoped access key, and write a .env for the @tigrisdata/storage package + examples: + - "tigris project setup" + - "tigris project setup --bucket my-app --access public" + messages: + onStart: 'Setting up Tigris project...' + onSuccess: 'Project ready' + onFailure: 'Failed to set up project' + nextActions: + - command: 'claude' + description: 'Start a new agent session to adapt your app to use Tigris' + arguments: + - name: bucket + description: 'Name of the bucket to create (default: a randomly generated name)' + alias: b + - name: access + description: Access level for the bucket + alias: a + options: *access_options + default: private + - name: locations + description: Location(s) for the bucket, comma-separated + alias: l + options: *location_options + default: 'global' + - name: env-file + description: Path to the .env file to write + default: '.env' + - name: force + type: flag + description: Overwrite the .env file if it already exists + - name: skip-agent-setup + type: flag + description: Skip installing Tigris agent skills + - name: skip-install + type: flag + description: Skip installing the @tigrisdata/storage and @tigrisdata/agent-kit npm packages diff --git a/test/lib/project/agent-setup.test.ts b/test/lib/project/agent-setup.test.ts new file mode 100644 index 0000000..0395434 --- /dev/null +++ b/test/lib/project/agent-setup.test.ts @@ -0,0 +1,136 @@ +import { + lstatSync, + mkdirSync, + mkdtempSync, + readlinkSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + installSkillsFromDir, + STORAGE_SKILLS, +} from '../../../src/lib/project/agent-setup.js'; + +describe('installSkillsFromDir', () => { + let dir: string; + let sourceSkillsDir: string; + let projectDir: string; + + function makeSkill(name: string) { + const skillDir = join(sourceSkillsDir, name); + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), `# ${name}\n`); + } + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'tigris-agent-setup-')); + sourceSkillsDir = join(dir, 'src-skills'); + projectDir = join(dir, 'project'); + mkdirSync(sourceSkillsDir, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('copies allowlisted skills into .agents/skills and symlinks .claude/skills', async () => { + makeSkill('tigris-bucket-management'); + makeSkill('tigris-object-operations'); + + const installed = await installSkillsFromDir(sourceSkillsDir, projectDir); + + expect(installed.sort()).toEqual([ + 'tigris-bucket-management', + 'tigris-object-operations', + ]); + + // Canonical copy exists with content. + const agentSkill = join( + projectDir, + '.agents', + 'skills', + 'tigris-bucket-management', + 'SKILL.md' + ); + expect(lstatSync(agentSkill).isFile()).toBe(true); + + // .claude/skills entry is a symlink with a relative target. + const link = join( + projectDir, + '.claude', + 'skills', + 'tigris-bucket-management' + ); + expect(lstatSync(link).isSymbolicLink()).toBe(true); + const target = readlinkSync(link); + expect(target).toBe( + join('..', '..', '.agents', 'skills', 'tigris-bucket-management') + ); + // …and it resolves back to the canonical copy. + expect(resolve(join(projectDir, '.claude', 'skills'), target)).toBe( + join(projectDir, '.agents', 'skills', 'tigris-bucket-management') + ); + }); + + it('ignores generic skills not on the storage allowlist', async () => { + makeSkill('conventional-commits'); + makeSkill('go-table-driven-tests'); + makeSkill('tigris-object-operations'); + + const installed = await installSkillsFromDir(sourceSkillsDir, projectDir); + + expect(installed).toEqual(['tigris-object-operations']); + }); + + it('does not clobber an existing real directory in .claude/skills', async () => { + makeSkill('tigris-object-operations'); + const claudeSkill = join( + projectDir, + '.claude', + 'skills', + 'tigris-object-operations' + ); + mkdirSync(claudeSkill, { recursive: true }); + writeFileSync(join(claudeSkill, 'mine.md'), 'keep me\n'); + + await installSkillsFromDir(sourceSkillsDir, projectDir); + + // Still a real directory, not a symlink, and our file survives. + expect(lstatSync(claudeSkill).isSymbolicLink()).toBe(false); + expect(lstatSync(join(claudeSkill, 'mine.md')).isFile()).toBe(true); + // But the canonical copy was still installed under .agents/skills. + expect( + lstatSync( + join(projectDir, '.agents', 'skills', 'tigris-object-operations') + ).isDirectory() + ).toBe(true); + }); + + it('replaces a stale symlink on reinstall', async () => { + makeSkill('tigris-object-operations'); + await installSkillsFromDir(sourceSkillsDir, projectDir); + // Re-running is idempotent and keeps a valid symlink. + const installed = await installSkillsFromDir(sourceSkillsDir, projectDir); + expect(installed).toEqual(['tigris-object-operations']); + const link = join( + projectDir, + '.claude', + 'skills', + 'tigris-object-operations' + ); + expect(lstatSync(link).isSymbolicLink()).toBe(true); + }); + + it('exposes only Tigris storage skills in the allowlist', () => { + expect(STORAGE_SKILLS).toContain('file-storage'); + expect(STORAGE_SKILLS).toContain('tigris-bucket-management'); + expect(STORAGE_SKILLS).not.toContain('conventional-commits'); + expect(STORAGE_SKILLS).not.toContain('go-table-driven-tests'); + }); +}); diff --git a/test/lib/project/packages.test.ts b/test/lib/project/packages.test.ts new file mode 100644 index 0000000..1e668fe --- /dev/null +++ b/test/lib/project/packages.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --- Mocks (hoisted) -------------------------------------------------------- + +vi.mock('../../../src/lib/project/exec.js', () => ({ + run: vi.fn(async () => {}), +})); + +import { run } from '../../../src/lib/project/exec.js'; +import { + installProjectPackages, + PROJECT_PACKAGES, +} from '../../../src/lib/project/packages.js'; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(run).mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('installProjectPackages', () => { + it('runs npm install for the project packages in the project dir', async () => { + const result = await installProjectPackages('/tmp/project'); + + expect(run).toHaveBeenCalledTimes(1); + const [command, args, options] = vi.mocked(run).mock.calls[0]; + expect(command).toBe('npm'); + expect(args).toEqual(['install', ...PROJECT_PACKAGES]); + expect((options as { cwd: string }).cwd).toBe('/tmp/project'); + + expect(result.installed).toEqual([...PROJECT_PACKAGES]); + expect(result.error).toBeUndefined(); + }); + + it('runs npm silently when quiet is set', async () => { + await installProjectPackages('/tmp/project', { quiet: true }); + const [, , options] = vi.mocked(run).mock.calls[0]; + expect((options as { stdio: string }).stdio).toBe('ignore'); + }); + + it('captures the error instead of throwing when npm fails', async () => { + vi.mocked(run).mockRejectedValue(new Error('npm not found')); + + const result = await installProjectPackages('/tmp/project'); + + expect(result.installed).toEqual([]); + expect(result.error).toBe('npm not found'); + }); + + it('installs the storage SDK and the agent kit', () => { + expect(PROJECT_PACKAGES).toContain('@tigrisdata/storage'); + expect(PROJECT_PACKAGES).toContain('@tigrisdata/agent-kit'); + }); +}); diff --git a/test/lib/project/setup.test.ts b/test/lib/project/setup.test.ts new file mode 100644 index 0000000..3cef3d4 --- /dev/null +++ b/test/lib/project/setup.test.ts @@ -0,0 +1,227 @@ +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --- Mocks (hoisted) -------------------------------------------------------- + +vi.mock('../../../src/auth/provider.js', () => ({ + getStorageConfig: vi.fn(async () => ({})), + getTigrisConfig: vi.fn(() => ({ endpoint: 'https://t3.storage.dev' })), + resolveAuthMethod: vi.fn(async () => ({ type: 'oauth' })), +})); + +vi.mock('../../../src/auth/iam.js', () => ({ + getIAMConfig: vi.fn(async () => ({})), +})); + +vi.mock('@tigrisdata/storage', () => ({ + createBucket: vi.fn(async () => ({ error: null })), + removeBucket: vi.fn(async () => ({ error: null })), +})); + +vi.mock('@tigrisdata/iam', () => ({ + createAccessKey: vi.fn(async () => ({ + data: { id: 'tid_test', secret: 'tsec_test', name: 'test-key' }, + error: null, + })), + assignBucketRoles: vi.fn(async () => ({ error: null })), +})); + +vi.mock('../../../src/lib/login/oauth.js', () => ({ + oauth: vi.fn(async () => {}), +})); + +vi.mock('../../../src/lib/project/agent-setup.js', () => ({ + setupAgentResources: vi.fn(async () => ({ mode: 'skills', installed: [] })), +})); + +vi.mock('../../../src/lib/project/packages.js', () => ({ + installProjectPackages: vi.fn(async () => ({ + installed: ['@tigrisdata/storage', '@tigrisdata/agent-kit'], + })), +})); + +vi.mock('../../../src/utils/exit.js', () => ({ + failWithError: vi.fn((_ctx: unknown, msg: unknown) => { + throw new Error(typeof msg === 'string' ? msg : String(msg)); + }), + printNextActions: vi.fn(), +})); + +vi.mock('../../../src/utils/messages.js', () => ({ + msg: vi.fn(() => ({ command: 'project', operation: 'setup' })), + printStart: vi.fn(), + printSuccess: vi.fn(), +})); + +import { assignBucketRoles, createAccessKey } from '@tigrisdata/iam'; +import { createBucket, removeBucket } from '@tigrisdata/storage'; + +import { resolveAuthMethod } from '../../../src/auth/provider.js'; +import { oauth } from '../../../src/lib/login/oauth.js'; +import { setupAgentResources } from '../../../src/lib/project/agent-setup.js'; +import { installProjectPackages } from '../../../src/lib/project/packages.js'; +import setup from '../../../src/lib/project/setup.js'; + +// --- Helpers ---------------------------------------------------------------- + +let dir: string; +const envPath = () => join(dir, '.env'); + +beforeEach(() => { + vi.clearAllMocks(); + // Re-apply default resolved values cleared by clearAllMocks. + vi.mocked(resolveAuthMethod).mockResolvedValue({ type: 'oauth' } as never); + vi.mocked(createBucket).mockResolvedValue({ error: null } as never); + vi.mocked(removeBucket).mockResolvedValue({ error: null } as never); + vi.mocked(createAccessKey).mockResolvedValue({ + data: { id: 'tid_test', secret: 'tsec_test', name: 'test-key' }, + error: null, + } as never); + vi.mocked(assignBucketRoles).mockResolvedValue({ error: null } as never); + dir = mkdtempSync(join(tmpdir(), 'tigris-project-setup-')); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +// --- Tests ------------------------------------------------------------------ + +describe('project setup', () => { + it('creates a bucket, an Editor access key, and writes the .env', async () => { + await setup({ 'env-file': envPath() }); + + expect(createBucket).toHaveBeenCalledTimes(1); + const [bucketName, bucketOpts] = vi.mocked(createBucket).mock.calls[0]; + expect(typeof bucketName).toBe('string'); + expect(bucketName.length).toBeGreaterThan(0); + expect((bucketOpts as { access: string }).access).toBe('private'); + + expect(createAccessKey).toHaveBeenCalledTimes(1); + expect(vi.mocked(createAccessKey).mock.calls[0][0]).toBe( + `${bucketName}-key` + ); + + expect(assignBucketRoles).toHaveBeenCalledTimes(1); + const [keyId, assignments] = vi.mocked(assignBucketRoles).mock.calls[0]; + expect(keyId).toBe('tid_test'); + expect(assignments).toEqual([{ bucket: bucketName, role: 'Editor' }]); + + const contents = readFileSync(envPath(), 'utf-8'); + expect(contents).toContain('TIGRIS_STORAGE_ACCESS_KEY_ID=tid_test'); + expect(contents).toContain('TIGRIS_STORAGE_SECRET_ACCESS_KEY=tsec_test'); + expect(contents).toContain( + 'TIGRIS_STORAGE_ENDPOINT=https://t3.storage.dev' + ); + expect(contents).toContain(`TIGRIS_STORAGE_BUCKET=${bucketName}`); + }); + + it('uses the provided bucket name when --bucket is given', async () => { + await setup({ 'env-file': envPath(), bucket: 'my-app' }); + + expect(vi.mocked(createBucket).mock.calls[0][0]).toBe('my-app'); + expect(readFileSync(envPath(), 'utf-8')).toContain( + 'TIGRIS_STORAGE_BUCKET=my-app' + ); + }); + + it('aborts when a .env already exists and --force is not set', async () => { + writeFileSync(envPath(), 'EXISTING=1\n'); + + await expect(setup({ 'env-file': envPath() })).rejects.toThrow( + /already exists/ + ); + + expect(createBucket).not.toHaveBeenCalled(); + // Existing file is untouched. + expect(readFileSync(envPath(), 'utf-8')).toBe('EXISTING=1\n'); + }); + + it('overwrites an existing .env when --force is set', async () => { + writeFileSync(envPath(), 'EXISTING=1\n'); + + await setup({ 'env-file': envPath(), force: true }); + + expect(createBucket).toHaveBeenCalledTimes(1); + const contents = readFileSync(envPath(), 'utf-8'); + expect(contents).not.toContain('EXISTING=1'); + expect(contents).toContain('TIGRIS_STORAGE_ACCESS_KEY_ID=tid_test'); + }); + + it('triggers OAuth login when not authenticated', async () => { + vi.mocked(resolveAuthMethod).mockResolvedValue({ type: 'none' } as never); + + await setup({ 'env-file': envPath() }); + + expect(oauth).toHaveBeenCalledTimes(1); + expect(createBucket).toHaveBeenCalledTimes(1); + }); + + it('does not trigger OAuth when already authenticated', async () => { + await setup({ 'env-file': envPath() }); + expect(oauth).not.toHaveBeenCalled(); + }); + + it('installs agent resources by default', async () => { + await setup({ 'env-file': envPath() }); + expect(setupAgentResources).toHaveBeenCalledTimes(1); + }); + + it('installs the project npm packages by default', async () => { + await setup({ 'env-file': envPath() }); + expect(installProjectPackages).toHaveBeenCalledTimes(1); + }); + + it('skips package install when --skip-install is given', async () => { + await setup({ 'env-file': envPath(), 'skip-install': true }); + expect(installProjectPackages).not.toHaveBeenCalled(); + // The rest of setup still completes. + expect(readFileSync(envPath(), 'utf-8')).toContain( + 'TIGRIS_STORAGE_ACCESS_KEY_ID=tid_test' + ); + }); + + it('skips agent setup when --skip-agent-setup is given', async () => { + await setup({ 'env-file': envPath(), 'skip-agent-setup': true }); + expect(setupAgentResources).not.toHaveBeenCalled(); + // The rest of setup still completes. + expect(readFileSync(envPath(), 'utf-8')).toContain( + 'TIGRIS_STORAGE_ACCESS_KEY_ID=tid_test' + ); + }); + + it('rolls back the bucket and does not write .env if access-key creation fails', async () => { + vi.mocked(createAccessKey).mockResolvedValue({ + data: null, + error: new Error('key boom'), + } as never); + + await expect(setup({ 'env-file': envPath() })).rejects.toThrow(/key boom/); + + expect(removeBucket).toHaveBeenCalledTimes(1); + expect(assignBucketRoles).not.toHaveBeenCalled(); + expect(existsSync(envPath())).toBe(false); + }); + + it('rolls back the bucket if role assignment fails', async () => { + vi.mocked(assignBucketRoles).mockResolvedValue({ + error: new Error('assign boom'), + } as never); + + await expect(setup({ 'env-file': envPath() })).rejects.toThrow( + /assign boom/ + ); + + expect(removeBucket).toHaveBeenCalledTimes(1); + expect(existsSync(envPath())).toBe(false); + }); +});