diff --git a/README.md b/README.md index 641c23e1..c34e2388 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,33 @@ 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. +## Project context + +Select a default project per profile, or link a directory so every command run +inside it uses one project: + +```shell +polymorfa project list # projects for the current credential; * marks the current one +polymorfa project use my-app # store the project on the active profile +polymorfa project use my-app --profile work +polymorfa link --project my-app # write polymorfa.json here +polymorfa project current # show the project and where it comes from +``` + +Commands resolve the project in this order: `--project`, the nearest +`polymorfa.json` in the current directory or a parent, then the +profile's stored project. Organization-owned commands ignore linked and profile +projects. `--verbose` prints the link file in use. + +`project use` and `link` accept a UUID or a unique slug and store the canonical +UUID. The link file holds only `{"version": 1, "project": ""}`, never a +credential, so it is safe to commit. `link` without `--project` uses the +profile's project, and replacing a link to a different project requires +`--force`. Project-token profiles stay bound to their one project: `project use` +rejects any other project, and a command fails if the directory is linked to a +different project. `polymorfa listen` falls back to the linked or profile +project when `--project` is omitted. + ## QuickLinks A QuickLink is a persistent hosted link that connects one WhatsApp number to a diff --git a/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md b/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md index a545f7bd..b1b24a4e 100644 --- a/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md +++ b/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md @@ -265,11 +265,10 @@ credential references. CI normally supplies credentials through environment variables and receives an explicit warning if an interactive profile is being used unexpectedly. -`polymorfa link` creates `.polymorfa/project.json` with non-secret organization, -project, and environment identifiers and adds the local file to `.gitignore` by -default. `polymorfa unlink` removes only the local link. Commands always show a -clear distinction between changing local context and changing a remote -resource. +`polymorfa link` creates `polymorfa.json` holding only the file version and the +canonical project UUID. The file carries no credentials, so it is safe to +commit. Deleting it removes the link. Commands always show a clear distinction +between changing local context and changing a remote resource. ## Command tree diff --git a/package.json b/package.json index ce99677a..d8cc6a9e 100644 --- a/package.json +++ b/package.json @@ -188,6 +188,9 @@ "presence:chat": { "description": "View and renew policy-governed chat presence observations." }, + "project": { + "description": "Select, list, and inspect the project context." + }, "project-token": { "description": "Inspect project-token metadata." }, diff --git a/src/base-command.ts b/src/base-command.ts index 2a54b872..dbea60f6 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -10,6 +10,7 @@ import { resolveListenerEnvironment, type ResolvedEnvironment, } from './context/environment.js' +import {findProjectLink} from './config/project-link.js' import {presentError} from './errors/present.js' import {errorEnvelope, type OutputContext} from './output/contracts.js' import {renderValue, type OutputMode} from './output/render.js' @@ -87,8 +88,10 @@ export abstract class BaseCommand extends Command { protected async developerSession( flags: GlobalFlags, ): Promise { - const session = await createDeveloperSession(flags, (secret) => - this.registerSecret(secret), + const session = await createDeveloperSession( + flags, + (secret) => this.registerSecret(secret), + await this.linkedProject(flags), ) this.errorContext = session.context return session @@ -115,8 +118,10 @@ export abstract class BaseCommand extends Command { client: MessagingClient projectId: string }> { + const linkedProject = await this.linkedProject(flags) const environment = await resolveListenerEnvironment({ env: process.env, + ...(linkedProject === undefined ? {} : {linkedProject}), ...(flags.profile === undefined ? {} : {profile: flags.profile}), ...(flags.project === undefined ? {} : {project: flags.project}), ...(flags['api-version'] === undefined @@ -147,6 +152,21 @@ export abstract class BaseCommand extends Command { } } + /** + * Project from the nearest `polymorfa.json`, used only when + * --project is absent. It ranks above the profile's stored project. + */ + protected async linkedProject( + flags: GlobalFlags, + ): Promise { + if (flags.project !== undefined) return undefined + const link = await findProjectLink() + if (link !== undefined && flags.verbose === true) { + process.stderr.write(`Using project ${link.project} from ${link.path}\n`) + } + return link?.project + } + /** Resolve Messaging credentials once, for commands that also open sockets. */ protected async messagingEnvironment( flags: GlobalFlags, diff --git a/src/commands/link.ts b/src/commands/link.ts new file mode 100644 index 00000000..625137db --- /dev/null +++ b/src/commands/link.ts @@ -0,0 +1,36 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../base-command.js' +import {createProjectContextService} from '../config/project-service.js' + +export default class Link extends BaseCommand { + static override description = + 'Link the current directory to a project by writing polymorfa.json.' + + static override examples = [ + '<%= config.bin %> link --project my-app', + '<%= config.bin %> link --project 11111111-1111-4111-8111-111111111111 --force', + '<%= config.bin %> link', + ] + + static override flags = { + force: Flags.boolean({ + description: 'Replace an existing link to a different project.', + }), + } + + async run(): Promise { + const {flags} = await this.parse(Link) + const result = await createProjectContextService().link({ + force: flags.force, + profile: flags.profile, + project: flags.project, + }) + const label = result.slug === undefined ? '' : ` (${result.slug})` + this.writeValue( + result, + `Linked ${result.project}${label} in ${result.path}. Commands run in this directory use it unless you pass --project.`, + flags, + ) + } +} diff --git a/src/commands/listen.ts b/src/commands/listen.ts index 3d8f2dad..9f149d92 100644 --- a/src/commands/listen.ts +++ b/src/commands/listen.ts @@ -62,8 +62,7 @@ export default class Listen extends BaseCommand { }), project: Flags.string({ description: - 'Use a project UUID; organization API keys may use a unique slug.', - required: true, + 'Use a project UUID; organization API keys may use a unique slug. Defaults to the linked or profile project.', }), } @@ -71,10 +70,12 @@ export default class Listen extends BaseCommand { const {flags} = await this.parse(Listen) const {resolveListenerEnvironment} = await import('../context/environment.js') + const linkedProject = await this.linkedProject(flags) const environment = await resolveListenerEnvironment({ env: process.env, + ...(linkedProject === undefined ? {} : {linkedProject}), ...(flags.profile === undefined ? {} : {profile: flags.profile}), - project: flags.project, + ...(flags.project === undefined ? {} : {project: flags.project}), }) this.registerSecret(environment.credential) diff --git a/src/commands/project/current.ts b/src/commands/project/current.ts new file mode 100644 index 00000000..1368fd1b --- /dev/null +++ b/src/commands/project/current.ts @@ -0,0 +1,24 @@ +import {BaseCommand} from '../../base-command.js' +import {createProjectContextService} from '../../config/project-service.js' + +export default class ProjectCurrent extends BaseCommand { + static override description = + 'Show the project commands use here and where it comes from: --project, a directory link, or the profile.' + + async run(): Promise { + const {flags} = await this.parse(ProjectCurrent) + const result = await createProjectContextService().current({ + profile: flags.profile, + project: flags.project, + }) + const human = + result.project === undefined + ? 'No project selected. Run polymorfa link or polymorfa project use .' + : result.source === 'link' + ? `${result.project} (linked in ${result.linkPath})` + : result.source === 'profile' + ? `${result.project} (profile ${result.profile})` + : `${result.project} (--project)` + this.writeValue(result, human, flags) + } +} diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts new file mode 100644 index 00000000..70e42822 --- /dev/null +++ b/src/commands/project/list.ts @@ -0,0 +1,24 @@ +import {BaseCommand} from '../../base-command.js' +import {createProjectContextService} from '../../config/project-service.js' + +export default class ProjectList extends BaseCommand { + static override description = + 'List projects available to the current credential and mark the current one.' + + async run(): Promise { + const {flags} = await this.parse(ProjectList) + const projects = await createProjectContextService().list({ + profile: flags.profile, + }) + const human = + projects.length === 0 + ? 'No projects found.' + : projects + .map( + (project) => + `${project.current ? '*' : ' '} ${project.id}\t${project.slug ?? '-'}\t${project.name ?? '-'}\t${project.stage ?? '-'}`, + ) + .join('\n') + this.writeValue(projects, human, flags) + } +} diff --git a/src/commands/project/use.ts b/src/commands/project/use.ts new file mode 100644 index 00000000..1f1076e6 --- /dev/null +++ b/src/commands/project/use.ts @@ -0,0 +1,35 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {createProjectContextService} from '../../config/project-service.js' + +export default class ProjectUse extends BaseCommand { + static override args = { + project: Args.string({ + description: 'Project UUID or slug.', + required: true, + }), + } + + static override description = + 'Store the default project for the active or selected profile.' + + static override examples = [ + '<%= config.bin %> project use my-app', + '<%= config.bin %> project use 11111111-1111-4111-8111-111111111111 --profile work', + ] + + async run(): Promise { + const {args, flags} = await this.parse(ProjectUse) + const result = await createProjectContextService().use({ + profile: flags.profile, + project: args.project, + }) + const label = result.slug === undefined ? '' : ` (${result.slug})` + this.writeValue( + result, + `Profile ${result.profile} now uses project ${result.project}${label}.`, + flags, + ) + } +} diff --git a/src/config/project-link.ts b/src/config/project-link.ts new file mode 100644 index 00000000..07d66f5e --- /dev/null +++ b/src/config/project-link.ts @@ -0,0 +1,104 @@ +import {readFile, rename, unlink, writeFile} from 'node:fs/promises' +import {randomUUID} from 'node:crypto' +import {dirname, join, parse, resolve} from 'node:path' + +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +/** Directory-scoped project link. It never holds credentials. */ +export const LINK_FILE = 'polymorfa.json' + +/** + * The canonical project identifier. The link file is hand-editable, so both + * the read boundary here and the project service enforce the same shape. + */ +export const PROJECT_UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +export interface ProjectLink { + readonly project: string + readonly version: 1 +} + +export interface FoundProjectLink extends ProjectLink { + readonly path: string +} + +export function linkPath(directory: string): string { + return join(resolve(directory), LINK_FILE) +} + +/** Find the nearest link file in `start` or one of its ancestors. */ +export async function findProjectLink( + start: string = process.cwd(), +): Promise { + let directory = resolve(start) + const {root} = parse(directory) + for (;;) { + const path = linkPath(directory) + const link = await readProjectLink(path) + if (link !== undefined) return {...link, path} + if (directory === root) return undefined + directory = dirname(directory) + } +} + +export async function readProjectLink( + path: string, +): Promise { + let serialized: string + try { + serialized = await readFile(path, 'utf8') + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + if (code === 'ENOENT' || code === 'ENOTDIR') return undefined + throw new PolymorfaConfigurationError( + `Could not read project link at ${path}.`, + 'link', + ) + } + let parsed: unknown + try { + parsed = JSON.parse(serialized) + } catch { + throw invalidLink(path) + } + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) || + (parsed as {version?: unknown}).version !== 1 || + typeof (parsed as {project?: unknown}).project !== 'string' || + !PROJECT_UUID.test((parsed as {project: string}).project.trim()) || + Object.keys(parsed).some((key) => key !== 'version' && key !== 'project') + ) { + throw invalidLink(path) + } + return {project: (parsed as {project: string}).project, version: 1} +} + +export async function writeProjectLink( + directory: string, + project: string, +): Promise { + const path = linkPath(directory) + const temporary = join(dirname(path), `.polymorfa-${randomUUID()}.tmp`) + const link: ProjectLink = {project, version: 1} + try { + await writeFile(temporary, `${JSON.stringify(link, undefined, 2)}\n`, { + encoding: 'utf8', + mode: 0o644, + }) + await rename(temporary, path) + } catch (error) { + await unlink(temporary).catch(() => undefined) + throw error + } + return path +} + +function invalidLink(path: string): PolymorfaConfigurationError { + return new PolymorfaConfigurationError( + `Project link at ${path} is invalid. Run polymorfa link again or delete the file.`, + 'link', + ) +} diff --git a/src/config/project-service.ts b/src/config/project-service.ts new file mode 100644 index 00000000..0cd7b3dd --- /dev/null +++ b/src/config/project-service.ts @@ -0,0 +1,10 @@ +import {SecureCredentialStore} from './credentials.js' +import {ProfileRepository} from './profiles.js' +import {ProjectContextService} from '../services/projects.js' + +export function createProjectContextService(): ProjectContextService { + return new ProjectContextService( + new ProfileRepository(), + new SecureCredentialStore(), + ) +} diff --git a/src/context/environment.ts b/src/context/environment.ts index 1f1554b6..86345d07 100644 --- a/src/context/environment.ts +++ b/src/context/environment.ts @@ -50,6 +50,8 @@ export interface ListenerEnvironment { export interface ResolveListenerEnvironmentInput { readonly apiVersion?: string | undefined readonly env: Readonly> + /** Project from the nearest `polymorfa link` file; below --project. */ + readonly linkedProject?: string | undefined readonly profile?: string | undefined readonly project?: string | undefined readonly timeoutMs?: number | undefined @@ -66,6 +68,8 @@ export interface DeveloperEnvironment { export interface ResolveDeveloperEnvironmentInput { readonly apiVersion?: string | undefined readonly env: Readonly> + /** Project from the nearest `polymorfa link` file; below --project. */ + readonly linkedProject?: string | undefined readonly owner?: string | undefined readonly profile?: string | undefined readonly project?: string | undefined @@ -93,7 +97,9 @@ export async function resolveDeveloperEnvironment( if (explicitProfile === undefined && environmentCredential !== undefined) { const baseUrl = nonEmpty(input.env.POLYMORFA_BASE_URL) ?? 'https://api.polymorfa.com' - const requestedProject = nonEmpty(input.project) + const requestedProject = + nonEmpty(input.project) ?? + (input.owner === 'project' ? nonEmpty(input.linkedProject) : undefined) const unresolvedContext = resolveOwnerContext({ principalType: environmentCredential.principalType, ...(input.owner === undefined ? {} : {owner: input.owner}), @@ -169,8 +175,23 @@ export async function resolveDeveloperEnvironment( 'project', ) } + const linkedProject = nonEmpty(input.linkedProject) + if ( + principalType === 'project_token' && + owner === 'project' && + explicitProject === undefined && + linkedProject !== undefined && + profile.project !== linkedProject + ) { + throw new PolymorfaConfigurationError( + `This directory is linked to project ${linkedProject}, but project-token profile ${selected} is bound to ${profile.project ?? 'another project'}. Pass --project or run polymorfa link again.`, + 'project', + ) + } const unresolvedProject = - owner === 'project' ? (explicitProject ?? profile.project) : explicitProject + owner === 'project' + ? (explicitProject ?? linkedProject ?? profile.project) + : explicitProject const baseUrl = nonEmpty(input.env.POLYMORFA_BASE_URL) ?? profile.baseUrl ?? @@ -231,7 +252,7 @@ export async function resolveListenerEnvironment( 'credential', ) } - const project = nonEmpty(input.project) + const project = nonEmpty(input.project) ?? nonEmpty(input.linkedProject) if (projectToken !== undefined) { if (project === undefined) { throw new PolymorfaConfigurationError( @@ -322,7 +343,7 @@ export async function resolveListenerEnvironment( profile.project !== project ) { throw new PolymorfaConfigurationError( - 'A project-token profile cannot listen to a different --project context.', + 'A project-token profile cannot listen to a different --project or linked project context.', 'project', ) } @@ -516,7 +537,7 @@ function durableEnvironmentCredential( const PROJECT_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ -async function canonicalProjectId( +export async function canonicalProjectId( project: string | undefined, principalType: 'organization_key' | 'project_token', connection: Parameters[0], @@ -554,7 +575,7 @@ async function canonicalProjectId( return projectId } -async function listProjects( +export async function listProjects( input: Parameters[0], ): Promise { const client = new Client({ diff --git a/src/developer/client.ts b/src/developer/client.ts index 4ecbaad8..a5c4affd 100644 --- a/src/developer/client.ts +++ b/src/developer/client.ts @@ -26,9 +26,11 @@ export interface DeveloperSession { export async function createDeveloperSession( flags: GlobalFlags, registerSecret?: (secret: string) => void, + linkedProject?: string, ): Promise { const environment = await resolveDeveloperEnvironment({ env: process.env, + ...(linkedProject === undefined ? {} : {linkedProject}), ...(flags['api-version'] === undefined ? {} : {apiVersion: flags['api-version']}), diff --git a/src/services/projects.ts b/src/services/projects.ts new file mode 100644 index 00000000..8f81e84f --- /dev/null +++ b/src/services/projects.ts @@ -0,0 +1,361 @@ +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +import type {CredentialStore} from '../config/credentials.js' +import type {ProfileMetadata} from '../config/profiles.js' +import { + findProjectLink, + linkPath, + PROJECT_UUID, + writeProjectLink, +} from '../config/project-link.js' +import {listProjects, type ProjectLookup} from '../context/environment.js' +import type {ProfileRepositoryLike} from './auth.js' + +const DEFAULT_BASE_URL = 'https://api.polymorfa.com' + +export type ProjectSource = 'flag' | 'link' | 'profile' | 'none' + +export interface CurrentProject { + readonly linkPath?: string + readonly profile?: string + readonly project?: string + readonly source: ProjectSource +} + +export interface ProjectListEntry { + readonly current: boolean + readonly id: string + readonly name?: string + readonly slug?: string + readonly stage?: 'development' | 'production' +} + +export interface ProjectSelection { + readonly name?: string + readonly profile: string + readonly project: string + readonly slug?: string +} + +export interface LinkResult { + readonly name?: string + readonly path: string + readonly project: string + readonly slug?: string +} + +interface ResolvedPrincipal { + readonly apiVersion?: string + readonly baseUrl: string + readonly credential: string + readonly kind: 'apiKey' | 'projectToken' + readonly metadata?: ProfileMetadata + readonly profile?: string + readonly tokenProject?: string +} + +export interface ProjectContextOptions { + readonly cwd?: string + readonly env?: Readonly> + readonly lookup?: ProjectLookup +} + +/** + * Directory links and per-profile project context. The profile store keeps + * metadata only; credentials stay in the operating-system keyring. + */ +export class ProjectContextService { + private readonly cwd: string + private readonly env: Readonly> + private readonly lookup: ProjectLookup + + constructor( + private readonly profiles: ProfileRepositoryLike, + private readonly credentials: CredentialStore, + options: ProjectContextOptions = {}, + ) { + this.cwd = options.cwd ?? process.cwd() + this.env = options.env ?? process.env + this.lookup = options.lookup ?? listProjects + } + + async current(input: { + readonly profile?: string | undefined + readonly project?: string | undefined + }): Promise { + // Environment credentials override the active profile, so its stored + // project does not apply unless --profile is explicit. + const environmentCredential = + nonEmpty(input.profile) === undefined && + (nonEmpty(this.env.POLYMORFA_API_KEY) !== undefined || + nonEmpty(this.env.POLYMORFA_PROJECT_TOKEN) !== undefined) + const profile = environmentCredential + ? undefined + : await this.profileName(input.profile) + const flag = nonEmpty(input.project) + if (flag !== undefined) { + return {project: flag, source: 'flag', ...optional('profile', profile)} + } + const link = await findProjectLink(this.cwd) + if (link !== undefined) { + return { + linkPath: link.path, + project: link.project, + source: 'link', + ...optional('profile', profile), + } + } + if (profile !== undefined) { + const config = await this.profiles.load() + const stored = config.profiles[profile]?.project + if (stored !== undefined) { + return {profile, project: stored, source: 'profile'} + } + } + return {source: 'none', ...optional('profile', profile)} + } + + async list(input: { + readonly profile?: string | undefined + }): Promise { + const principal = await this.principal(input.profile) + const current = (await this.current({profile: input.profile})).project + if (principal.kind === 'projectToken') { + const id = principal.tokenProject as string + return [{current: current === id, id}] + } + const projects = await this.lookup(connection(principal)) + return [...projects] + .sort((left, right) => left.slug.localeCompare(right.slug)) + .map((project) => ({ + current: current === project._id || current === project.slug, + id: project._id, + name: project.name, + slug: project.slug, + stage: project.stage, + })) + } + + async use(input: { + readonly profile?: string | undefined + readonly project: string + }): Promise { + const selected = await this.profileName(input.profile) + if (selected === undefined) { + throw new PolymorfaConfigurationError( + 'Project context is stored per profile. Run polymorfa auth login or pass --profile .', + 'profile', + ) + } + const principal = await this.principal(selected, {requireProfile: true}) + const resolved = await this.resolveProject(principal, input.project) + const config = await this.profiles.load() + const metadata = config.profiles[selected] as ProfileMetadata + await this.profiles.save({ + ...config, + profiles: { + ...config.profiles, + [selected]: {...metadata, owner: 'project', project: resolved.id}, + }, + }) + return { + profile: selected, + project: resolved.id, + ...optional('name', resolved.name), + ...optional('slug', resolved.slug), + } + } + + async link(input: { + readonly directory?: string | undefined + readonly force: boolean + readonly profile?: string | undefined + readonly project?: string | undefined + }): Promise { + const directory = input.directory ?? this.cwd + const principal = await this.principal(input.profile, { + project: input.project, + }) + const reference = + nonEmpty(input.project) ?? + principal.metadata?.project ?? + principal.tokenProject + if (reference === undefined) { + throw new PolymorfaConfigurationError( + 'Pass --project , or select one with polymorfa project use first.', + 'project', + ) + } + const resolved = await this.resolveProject(principal, reference) + const existing = await findProjectLink(directory) + const target = linkPath(directory) + if ( + existing !== undefined && + existing.path === target && + existing.project !== resolved.id && + !input.force + ) { + throw new PolymorfaConfigurationError( + `${target} already links project ${existing.project}. Pass --force to replace it.`, + 'link', + ) + } + const path = await writeProjectLink(directory, resolved.id) + return { + path, + project: resolved.id, + ...optional('name', resolved.name), + ...optional('slug', resolved.slug), + } + } + + private async resolveProject( + principal: ResolvedPrincipal, + reference: string, + ): Promise<{id: string; name?: string; slug?: string}> { + const value = reference.trim() + if (principal.kind === 'projectToken') { + if (value !== principal.tokenProject) { + throw new PolymorfaConfigurationError( + `Project tokens are bound to one project (${principal.tokenProject ?? 'unknown'}) and cannot select ${value}.`, + 'project', + ) + } + return {id: value} + } + const matches = (await this.lookup(connection(principal))).filter( + (project) => project._id === value || project.slug === value, + ) + if (matches.length === 0) { + throw new PolymorfaConfigurationError( + `Project ${value} was not found in the organization.`, + 'project', + ) + } + if (matches.length > 1) { + throw new PolymorfaConfigurationError( + `Project slug ${value} is ambiguous; use the project UUID.`, + 'project', + ) + } + const match = matches[0] as (typeof matches)[number] + if (!PROJECT_UUID.test(match._id)) { + throw new PolymorfaConfigurationError( + 'The project list returned an invalid project identifier.', + 'project', + ) + } + return {id: match._id, name: match.name, slug: match.slug} + } + + private async profileName( + requested: string | undefined, + ): Promise { + const explicit = nonEmpty(requested) + if (explicit !== undefined) return explicit + return (await this.profiles.load()).activeProfile + } + + private async principal( + requested: string | undefined, + options: { + readonly project?: string | undefined + readonly requireProfile?: boolean + } = {}, + ): Promise { + const explicit = nonEmpty(requested) + if (explicit === undefined && options.requireProfile !== true) { + const apiKey = nonEmpty(this.env.POLYMORFA_API_KEY) + const projectToken = nonEmpty(this.env.POLYMORFA_PROJECT_TOKEN) + if (apiKey !== undefined && projectToken !== undefined) { + throw new PolymorfaConfigurationError( + 'Credentials are ambiguous; set only POLYMORFA_API_KEY or POLYMORFA_PROJECT_TOKEN.', + 'credential', + ) + } + const baseUrl = nonEmpty(this.env.POLYMORFA_BASE_URL) ?? DEFAULT_BASE_URL + if (apiKey !== undefined) { + return {baseUrl, credential: apiKey, kind: 'apiKey'} + } + if (projectToken !== undefined) { + // The token does not name its project, so an explicit UUID is the + // only way to identify it locally. A slug cannot be resolved without + // an organization credential. + const declared = nonEmpty(options.project)?.trim() + if (declared !== undefined && PROJECT_UUID.test(declared)) { + return { + baseUrl, + credential: projectToken, + kind: 'projectToken', + tokenProject: declared, + } + } + throw new PolymorfaConfigurationError( + 'POLYMORFA_PROJECT_TOKEN does not identify its project locally. Pass --project with the exact project UUID, or use a project-token profile.', + 'project', + ) + } + } + + const config = await this.profiles.load() + const name = explicit ?? config.activeProfile + if (name === undefined) { + throw new PolymorfaConfigurationError( + 'No credential is configured. Set POLYMORFA_API_KEY or run polymorfa auth login.', + 'credential', + ) + } + const metadata = config.profiles[name] + if (metadata === undefined) { + throw new PolymorfaConfigurationError( + `Profile ${name} does not exist.`, + 'profile', + ) + } + const credential = await this.credentials.get(metadata.credential.account) + if (credential === undefined) { + throw new PolymorfaConfigurationError( + `Profile ${name} has no credential in the operating-system keyring.`, + 'keyring', + ) + } + const apiVersion = metadata.apiVersion + return { + baseUrl: + nonEmpty(this.env.POLYMORFA_BASE_URL) ?? + metadata.baseUrl ?? + DEFAULT_BASE_URL, + credential, + kind: metadata.credential.kind, + metadata, + profile: name, + ...(apiVersion === undefined ? {} : {apiVersion}), + ...(metadata.credential.kind === 'projectToken' && + metadata.project !== undefined + ? {tokenProject: metadata.project} + : {}), + } + } +} + +function connection(principal: ResolvedPrincipal) { + return { + baseUrl: principal.baseUrl, + credential: principal.credential, + ...(principal.apiVersion === undefined + ? {} + : {apiVersion: principal.apiVersion}), + } +} + +function optional( + key: K, + value: V | undefined, +): Partial> { + return value === undefined ? {} : ({[key]: value} as Record) +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed +} diff --git a/test/commands/project-context.test.ts b/test/commands/project-context.test.ts new file mode 100644 index 00000000..4179965e --- /dev/null +++ b/test/commands/project-context.test.ts @@ -0,0 +1,90 @@ +import {mkdtemp, readFile, rm} 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 { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const APP = '11111111-1111-4111-8111-111111111111' + +describe('link and project commands', () => { + let directory: string + let original: string + let server: TestHttpServer | undefined + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-cli-project-')) + original = process.cwd() + process.chdir(directory) + }) + + afterEach(async () => { + process.chdir(original) + await server?.close() + server = undefined + await rm(directory, {force: true, recursive: true}) + }) + + test('links the directory by slug and reports the linked project', async () => { + server = await startHttpServer({ + body: { + data: [ + { + _creationTime: 0, + _id: APP, + name: 'App', + orgId: 'org_1', + slug: 'app', + stage: 'development', + }, + ], + success: true, + }, + }) + + const env = { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_CONFIG_DIR: join(directory, 'config'), + POLYMORFA_PROJECT_TOKEN: undefined, + } + const link = await withEnvironment(env, () => + runCommand('link --project app --json'), + ) + expect(link.error, link.stderr).toBeUndefined() + expect(JSON.parse(link.stdout)).toMatchObject({project: APP, slug: 'app'}) + expect( + JSON.parse(await readFile(join(directory, 'polymorfa.json'), 'utf8')), + ).toEqual({project: APP, version: 1}) + + const current = await withEnvironment(env, () => + runCommand('project current --json'), + ) + expect(JSON.parse(current.stdout)).toMatchObject({ + project: APP, + source: 'link', + }) + }) + + test('reports no project outside a link without credentials', async () => { + const result = await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_CONFIG_DIR: join(directory, 'config'), + POLYMORFA_PROJECT_TOKEN: undefined, + }, + () => runCommand('project current'), + ) + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('No project selected') + }) +}) diff --git a/test/services/projects.test.ts b/test/services/projects.test.ts new file mode 100644 index 00000000..c0a824df --- /dev/null +++ b/test/services/projects.test.ts @@ -0,0 +1,311 @@ +import {mkdir, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import type {ProjectWithStats} from '@polymorfa/sdk' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' + +import type {CredentialStore} from '../../src/config/credentials.js' +import type {ProfileConfig} from '../../src/config/profiles.js' +import { + findProjectLink, + writeProjectLink, +} from '../../src/config/project-link.js' +import { + resolveDeveloperEnvironment, + resolveListenerEnvironment, +} from '../../src/context/environment.js' +import {ProjectContextService} from '../../src/services/projects.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const APP = '11111111-1111-4111-8111-111111111111' +const WEB = '22222222-2222-4222-8222-222222222222' + +describe('project context', () => { + let directory: string + let config: ProfileConfig + const saved: ProfileConfig[] = [] + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-cli-link-')) + config = { + activeProfile: 'work', + profiles: { + token: { + credential: { + account: 'profile:token:projectToken', + kind: 'projectToken', + }, + owner: 'project', + project: APP, + }, + work: {credential: {account: 'profile:work:apiKey', kind: 'apiKey'}}, + }, + version: 2, + } + saved.length = 0 + }) + + afterEach(async () => { + await rm(directory, {force: true, recursive: true}) + }) + + const profiles = { + load: () => Promise.resolve(config), + save: (next: ProfileConfig) => { + config = next + saved.push(next) + return Promise.resolve() + }, + } + const credentials: CredentialStore = { + delete: () => Promise.resolve(), + get: (account) => + Promise.resolve( + account === 'profile:work:apiKey' + ? API_KEY + : account === 'profile:token:projectToken' + ? 'pmfa_pt_x' + : undefined, + ), + set: () => Promise.resolve(), + } + const lookup = vi.fn(() => + Promise.resolve([ + project(WEB, 'web', 'Web'), + project(APP, 'app', 'App'), + ] as readonly ProjectWithStats[]), + ) + + function service(env: Record = {}) { + return new ProjectContextService(profiles, credentials, { + cwd: directory, + env, + lookup, + }) + } + + test('use stores the canonical project on the active profile only', async () => { + const result = await service().use({project: 'app'}) + + expect(result).toEqual({ + name: 'App', + profile: 'work', + project: APP, + slug: 'app', + }) + expect(config.profiles.work).toEqual({ + credential: {account: 'profile:work:apiKey', kind: 'apiKey'}, + owner: 'project', + project: APP, + }) + expect(config.profiles.token?.project).toBe(APP) + expect(JSON.stringify(config)).not.toContain(API_KEY) + }) + + test('use rejects unknown projects without saving', async () => { + await expect(service().use({project: 'missing'})).rejects.toThrow( + 'was not found', + ) + expect(saved).toHaveLength(0) + }) + + test('project-token profiles cannot switch to another project', async () => { + await expect( + service().use({profile: 'token', project: WEB}), + ).rejects.toThrow('bound to one project') + await expect( + service().use({profile: 'token', project: APP}), + ).resolves.toMatchObject({project: APP}) + expect(lookup).not.toHaveBeenCalled() + }) + + test('link writes a credential-free file and current resolves it from subdirectories', async () => { + const result = await service().link({force: false, project: 'web'}) + + expect(result.project).toBe(WEB) + const contents = await readFile(join(directory, 'polymorfa.json'), 'utf8') + expect(JSON.parse(contents)).toEqual({project: WEB, version: 1}) + expect(contents).not.toContain('pmfa_') + + const nested = join(directory, 'src', 'deep') + await mkdir(nested, {recursive: true}) + const current = await new ProjectContextService(profiles, credentials, { + cwd: nested, + env: {}, + lookup, + }).current({}) + expect(current).toMatchObject({ + project: WEB, + profile: 'work', + source: 'link', + }) + }) + + test('link refuses to replace a different link without --force', async () => { + await service().link({force: false, project: 'web'}) + + await expect( + service().link({force: false, project: 'app'}), + ).rejects.toThrow('--force') + await expect( + service().link({force: true, project: 'app'}), + ).resolves.toMatchObject({project: APP}) + }) + + test('link defaults to the profile project', async () => { + await service().use({project: 'app'}) + await expect(service().link({force: false})).resolves.toMatchObject({ + project: APP, + }) + }) + + test('link accepts an explicit project UUID with an environment project token', async () => { + const env = {POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_x'} + + await expect( + service(env).link({force: false, project: APP}), + ).resolves.toMatchObject({ + path: join(directory, 'polymorfa.json'), + project: APP, + }) + expect(lookup).not.toHaveBeenCalled() + + await expect(service(env).link({force: false})).rejects.toThrow( + 'POLYMORFA_PROJECT_TOKEN does not identify its project locally', + ) + await expect( + service(env).link({force: true, project: 'app'}), + ).rejects.toThrow( + 'POLYMORFA_PROJECT_TOKEN does not identify its project locally', + ) + }) + + test('current prefers --project, then link, then profile', async () => { + await service().use({project: 'app'}) + expect(await service().current({})).toMatchObject({ + source: 'profile', + project: APP, + }) + + await writeProjectLink(directory, WEB) + expect(await service().current({})).toMatchObject({ + source: 'link', + project: WEB, + }) + expect(await service().current({project: 'other'})).toMatchObject({ + project: 'other', + source: 'flag', + }) + }) + + test('environment credentials hide the profile project from current', async () => { + await service().use({project: 'app'}) + expect(await service({POLYMORFA_API_KEY: API_KEY}).current({})).toEqual({ + source: 'none', + }) + }) + + test('list marks the current project', async () => { + await service().use({project: 'app'}) + const projects = await service().list({}) + + expect(projects.map((entry) => [entry.slug, entry.current])).toEqual([ + ['app', true], + ['web', false], + ]) + }) + + test('rejects malformed link files', async () => { + await writeFile( + join(directory, 'polymorfa.json'), + JSON.stringify({project: APP, token: 'secret', version: 1}), + ) + await expect(findProjectLink(directory)).rejects.toThrow('is invalid') + }) + + test('rejects a link file whose project is not a canonical UUID', async () => { + await writeFile( + join(directory, 'polymorfa.json'), + JSON.stringify({project: 'project_1', version: 1}), + ) + await expect(findProjectLink(directory)).rejects.toThrow('is invalid') + }) + + test('developer commands use the linked project above the profile project', async () => { + const withProject: ProfileConfig = { + ...config, + profiles: { + ...config.profiles, + work: {...config.profiles.work!, owner: 'project', project: APP}, + }, + } + const environment = await resolveDeveloperEnvironment( + {env: {}, linkedProject: WEB}, + {load: () => Promise.resolve(withProject), save: () => Promise.resolve()}, + credentials, + lookup, + ) + expect(environment.context.projectId).toBe(WEB) + + const explicit = await resolveDeveloperEnvironment( + {env: {}, linkedProject: WEB, project: APP}, + {load: () => Promise.resolve(withProject), save: () => Promise.resolve()}, + credentials, + lookup, + ) + expect(explicit.context.projectId).toBe(APP) + }) + + test('organization-owned commands ignore the linked project', async () => { + const environment = await resolveDeveloperEnvironment( + {env: {}, linkedProject: WEB, owner: 'organization'}, + profiles, + credentials, + lookup, + ) + expect(environment.context).toMatchObject({owner: 'organization'}) + expect(environment.context.projectId).toBeUndefined() + }) + + test('a project-token profile fails closed on a different linked project', async () => { + await expect( + resolveDeveloperEnvironment( + {env: {}, linkedProject: WEB, profile: 'token'}, + profiles, + credentials, + lookup, + ), + ).rejects.toThrow('is linked to project') + }) + + test('listen falls back to the linked project', async () => { + const environment = await resolveListenerEnvironment( + {env: {POLYMORFA_API_KEY: API_KEY}, linkedProject: WEB}, + profiles, + credentials, + lookup, + ) + expect(environment.projectId).toBe(WEB) + }) +}) + +function project(id: string, slug: string, name: string) { + return { + _creationTime: 0, + _id: id, + activeSessions: 0, + defaultTier: 'standard', + icon: {}, + iconUrl: null, + isActive: true, + lastActivity: null, + name, + orgId: 'org_1', + slug, + stage: 'development', + totalMessages: 0, + totalSessions: 0, + } +}