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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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": "<uuid>"}`, 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
Expand Down
9 changes: 4 additions & 5 deletions docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
24 changes: 22 additions & 2 deletions src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -87,8 +88,10 @@ export abstract class BaseCommand extends Command {
protected async developerSession(
flags: GlobalFlags,
): Promise<DeveloperSession> {
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
Expand All @@ -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
Expand Down Expand Up @@ -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<string | undefined> {
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,
Expand Down
36 changes: 36 additions & 0 deletions src/commands/link.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
)
}
}
7 changes: 4 additions & 3 deletions src/commands/listen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,20 @@ 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.',
}),
}

async run(): Promise<void> {
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)

Expand Down
24 changes: 24 additions & 0 deletions src/commands/project/current.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 <project>.'
: 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)
}
}
24 changes: 24 additions & 0 deletions src/commands/project/list.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
35 changes: 35 additions & 0 deletions src/commands/project/use.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
)
}
}
104 changes: 104 additions & 0 deletions src/config/project-link.ts
Original file line number Diff line number Diff line change
@@ -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<FoundProjectLink | undefined> {
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<ProjectLink | undefined> {
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<string> {
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',
)
}
10 changes: 10 additions & 0 deletions src/config/project-service.ts
Original file line number Diff line number Diff line change
@@ -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(),
)
}
Loading
Loading