Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,22 @@ in this development slice and does not yet claim remote identity validation.
Environment credentials take precedence over the active profile for automation.
An explicit `--profile <name>` takes precedence over environment credentials.

## Start a Node project

Create a Node.js and TypeScript starter in an existing directory:

```shell
polymorfa init
```

The command writes `package.json`, `src/index.ts`, `.env.example`, and
`README.md`. It pins an exact published SDK development version. It does not
read or write credentials, select a project, or overwrite
any existing starter file. If a target already exists, it stops before writing
anything. Set the example's `POLYMORFA_API_KEY` through your shell or secret
manager when you choose to run it. Use `polymorfa link` separately to associate
the directory with a project for CLI commands.

## Project context

Select a default project per profile, or link a directory so every command run
Expand Down
16 changes: 16 additions & 0 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {Command} from '@oclif/core'
import {initStarter} from '../services/init-starter.js'

export default class Init extends Command {
static override description =
'Create a credential-free Node.js and TypeScript starter in the current directory.'
static override examples = ['<%= config.bin %> init']

async run(): Promise<void> {
await this.parse(Init)
const result = await initStarter(process.cwd())
this.log(
`Created ${result.files.join(', ')} in ${result.directory}. Run npm install, then npm run typecheck.`,
)
}
}
120 changes: 120 additions & 0 deletions src/services/init-starter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {lstat, mkdir, open, rmdir, unlink} from 'node:fs/promises'
import {join, resolve} from 'node:path'

const FILE_NAMES = [
'package.json',
'.env.example',
'README.md',
'src/index.ts',
] as const

export const STARTER_SDK_VERSION = '0.1.0-dev.20260926231105'

export interface InitResult {
directory: string
files: readonly string[]
sdkVersion: string
}

export async function initStarter(directory: string): Promise<InitResult> {
const root = resolve(directory)
const rootStat = await lstat(root)
if (!rootStat.isDirectory())
throw new Error(`Starter target is not a directory: ${root}`)

// Refuse a partial starter before creating anything. In particular, an
// existing src directory is not ours to populate, even if it looks empty.
for (const name of [...FILE_NAMES.slice(0, 3), 'src']) {
if (await exists(join(root, name)))
throw new Error(`Starter target already exists: ${join(root, name)}`)
}

const files = starterFiles(STARTER_SDK_VERSION)
const created: string[] = []
let madeSourceDirectory = false
try {
await mkdir(join(root, 'src'))
madeSourceDirectory = true
for (const name of FILE_NAMES) {
const path = join(root, name)
const handle = await open(path, 'wx', 0o644)
created.push(path)
try {
await handle.writeFile(files[name], 'utf8')
} finally {
await handle.close()
}
}
} catch (error) {
for (const path of created.reverse())
await unlink(path).catch(() => undefined)
if (madeSourceDirectory)
await rmdir(join(root, 'src')).catch(() => undefined)
throw error
}

return {directory: root, files: FILE_NAMES, sdkVersion: STARTER_SDK_VERSION}
}

function starterFiles(
sdkVersion: string,
): Record<(typeof FILE_NAMES)[number], string> {
const manifest = {
name: 'polymorfa-starter',
private: true,
type: 'module',
engines: {node: '>=22'},
scripts: {
start: 'tsx src/index.ts',
typecheck:
'tsc --noEmit --strict --target es2022 --module nodenext --moduleResolution nodenext --types node src/index.ts',
},
dependencies: {'@polymorfa/sdk': sdkVersion},
devDependencies: {
'@types/node': '22.20.4',
tsx: '4.20.6',
typescript: '6.0.3',
},
}

return {
'package.json': `${JSON.stringify(manifest, null, 2)}\n`,
'.env.example': 'POLYMORFA_API_KEY=\n',
'README.md': `# Polymorfa Node starter

This example lists projects using the Polymorfa TypeScript SDK. It needs Node.js 22 or later and a team API key with permission to list projects. No credential is included in these files.

1. Run \`npm install\` and commit the generated lockfile.
2. Set \`POLYMORFA_API_KEY\` in your shell or secret manager. \`.env.example\` lists the required variable but contains no value.
3. Run \`npm run typecheck\`, then \`npm start\`.

The SDK dependency is pinned to an exact published development version. To select a project for separate CLI commands, run \`polymorfa link\` after creating a project; \`init\` does not link a project.
`,
'src/index.ts': `import {Client} from '@polymorfa/sdk'

const apiKey = process.env.POLYMORFA_API_KEY
if (!apiKey) {
console.error('Set POLYMORFA_API_KEY in your shell or secret manager.')
process.exitCode = 1
} else {
const client = new Client({
credential: {type: 'organizationApiKey', value: apiKey},
})
const response = await client.projects.list()
for (const project of response.data.data) {
console.log(project._id, project.slug)
}
}
`,
}
}

async function exists(path: string): Promise<boolean> {
try {
await lstat(path)
return true
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
throw error
}
}
94 changes: 94 additions & 0 deletions test/commands/init.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from 'node:fs/promises'
import {tmpdir} from 'node:os'
import {join} from 'node:path'

import {runCommand} from '@oclif/test'
import {afterEach, beforeEach, describe, expect, test} from 'vitest'

import {
initStarter,
STARTER_SDK_VERSION,
} from '../../src/services/init-starter.js'
import {withEnvironment} from '../helpers/http-server.js'

describe('init starter', () => {
let directory: string
let original: string

beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'polymorfa-cli-init-'))
original = process.cwd()
process.chdir(directory)
})

afterEach(async () => {
process.chdir(original)
await rm(directory, {force: true, recursive: true})
})

test('creates four credential-free files with an exact published SDK version', async () => {
const marker = 'pmfa_marker_must_not_appear_in_starter'
const result = await withEnvironment({POLYMORFA_API_KEY: marker}, () =>
runCommand('init'),
)
expect(result.error, result.stderr).toBeUndefined()
expect(result.stdout).toContain(
'Created package.json, .env.example, README.md, src/index.ts',
)

const manifest: unknown = JSON.parse(
await readFile(join(directory, 'package.json'), 'utf8'),
)
expect(manifest).toMatchObject({
dependencies: {'@polymorfa/sdk': STARTER_SDK_VERSION},
private: true,
Comment thread
purpshell marked this conversation as resolved.
})
expect((await readdir(directory)).sort()).toEqual([
'.env.example',
'README.md',
'package.json',
'src',
])
expect(await readdir(join(directory, 'src'))).toEqual(['index.ts'])
const contents = await Promise.all([
readFile(join(directory, 'package.json'), 'utf8'),
readFile(join(directory, '.env.example'), 'utf8'),
readFile(join(directory, 'README.md'), 'utf8'),
readFile(join(directory, 'src/index.ts'), 'utf8'),
])
expect(contents.join('\n')).not.toContain(marker)
expect(contents[1]).toBe('POLYMORFA_API_KEY=\n')
expect(contents[3]).toContain('client.projects.list()')
await expect(
lstat(join(directory, 'polymorfa.json')),
).rejects.toMatchObject({code: 'ENOENT'})
})

test.each(['package.json', '.env.example', 'README.md', 'src'])(
'refuses a partial target containing %s without changing it',
async (existing) => {
const target = join(directory, existing)
if (existing === 'src') await mkdir(target)
else await writeFile(target, 'preserve me')
await expect(initStarter(directory)).rejects.toThrow('already exists')
expect(await readdir(directory)).toEqual([existing])
if (existing !== 'src')
expect(await readFile(target, 'utf8')).toBe('preserve me')
},
)

test('refuses a second run without replacing the first output', async () => {
await initStarter(directory)
const before = await readFile(join(directory, 'package.json'), 'utf8')
await expect(initStarter(directory)).rejects.toThrow('already exists')
expect(await readFile(join(directory, 'package.json'), 'utf8')).toBe(before)
})
})