From d1c670982360eb62cc8e842524a68deb07ac0ab1 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 27 Sep 2026 01:08:41 +0200 Subject: [PATCH 1/3] feat(cli): add credential-free Node starter --- README.md | 16 +++++ src/commands/init.ts | 16 +++++ src/services/init-starter.ts | 120 +++++++++++++++++++++++++++++++++++ test/commands/init.test.ts | 94 +++++++++++++++++++++++++++ 4 files changed, 246 insertions(+) create mode 100644 src/commands/init.ts create mode 100644 src/services/init-starter.ts create mode 100644 test/commands/init.test.ts diff --git a/README.md b/README.md index 2d3d196..dc9b8db 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..d59f81e --- /dev/null +++ b/src/commands/init.ts @@ -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 { + 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.`, + ) + } +} diff --git a/src/services/init-starter.ts b/src/services/init-starter.ts new file mode 100644 index 0000000..b4d70df --- /dev/null +++ b/src/services/init-starter.ts @@ -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.20260926224018' + +export interface InitResult { + directory: string + files: readonly string[] + sdkVersion: string +} + +export async function initStarter(directory: string): Promise { + 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 { + try { + await lstat(path) + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw error + } +} diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts new file mode 100644 index 0000000..fc88aef --- /dev/null +++ b/test/commands/init.test.ts @@ -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, + }) + expect(await readdir(directory)).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) + }) +}) From 46d06dc32980b37d3cf0ef77d11e5e7f648e5d12 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 27 Sep 2026 01:13:00 +0200 Subject: [PATCH 2/3] test(cli): compare starter entries without filesystem order --- test/commands/init.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts index fc88aef..90cba96 100644 --- a/test/commands/init.test.ts +++ b/test/commands/init.test.ts @@ -51,7 +51,7 @@ describe('init starter', () => { dependencies: {'@polymorfa/sdk': STARTER_SDK_VERSION}, private: true, }) - expect(await readdir(directory)).toEqual([ + expect((await readdir(directory)).sort()).toEqual([ '.env.example', 'README.md', 'package.json', From 27f7e80f780f5cc0607705d3af8ac10358c50a2f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 27 Sep 2026 01:14:06 +0200 Subject: [PATCH 3/3] chore(cli): pin starter to verified SDK dev artifact --- src/services/init-starter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/init-starter.ts b/src/services/init-starter.ts index b4d70df..ff3ab16 100644 --- a/src/services/init-starter.ts +++ b/src/services/init-starter.ts @@ -8,7 +8,7 @@ const FILE_NAMES = [ 'src/index.ts', ] as const -export const STARTER_SDK_VERSION = '0.1.0-dev.20260926224018' +export const STARTER_SDK_VERSION = '0.1.0-dev.20260926231105' export interface InitResult { directory: string