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
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

The official command-line interface for Polymorfa.

The package is under active development and has not been published to npm.
The source repository is private, so GitHub installation requires repository
access until `@polymorfa/cli` is released publicly.
The package is under active development. `@polymorfa/cli` is not published to
npm yet, so `npm install --global @polymorfa/cli` does not work. Once the CLI is
published, that command will be the supported installation. Until then, install
from the private source repository, which requires repository access.

## Requirements

Expand All @@ -30,12 +31,20 @@ npm link
polymorfa version
```

The verified version output includes both runtime components:
Both methods install the CLI's dependencies, including the TypeScript SDK
`@polymorfa/sdk`, from the public npm registry. The CLI pins one exact SDK
prerelease in `package.json`.

The version output includes both runtime components:

```text
polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.0
```

The SDK component reports the version constant built into the SDK. SDK dev
prereleases report `0.1.0-dev.0`, not their full prerelease version; run
`npm ls @polymorfa/sdk` to see the installed build.

## Authentication profiles

Until the backend exposes a revocable CLI device-authorization flow, login
Expand Down
12 changes: 7 additions & 5 deletions docs/command-research/calls.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
- **Status:** Implemented against Calls contract revision 1, unpublished
- **Reviewed:** 2026-09-17
- **SDK baseline:** `@polymorfa/sdk` (including the `@polymorfa/sdk/calls`
subpath) at `polymorfa/sdks` commit
`94bfd09825817f842434e206e6a77adb5cc0c414`
subpath) `0.1.0-dev.20260922102711` from npm. The research was done against
`polymorfa/sdks` commit `94bfd09825817f842434e206e6a77adb5cc0c414`; the npm
build also reports a `claim` activity when the placed call is answered.

## Sources reviewed

Expand Down Expand Up @@ -129,6 +130,7 @@ internal SDK module.
- `calls media` does not attach to a call that was already ringing or answered
before it started, and carries no video.
- `calls watch` does not report outbound calls placed by other participants.
- `@polymorfa/sdk` (with its `@polymorfa/sdk/calls` subpath) is not
published; development pins an exact Git commit. The CLI cannot release
until `@polymorfa/sdk` is published with a compatible semver range.
- `@polymorfa/sdk` (with its `@polymorfa/sdk/calls` subpath) is published to
npm only as dev prereleases; the CLI pins exactly
`0.1.0-dev.20260922102711`. The CLI cannot release until a stable
`@polymorfa/sdk` is published with a compatible semver range.
43 changes: 35 additions & 8 deletions docs/development/bansafe-sdk-integration.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,39 @@
# BanSafe SDK integration check

The BanSafe CLI commands depend on the matching typed SDK revision pinned in
`package.json`. During coordinated SDK and CLI development, verify both
worktrees without replacing that immutable dependency in either manifest:
The BanSafe CLI commands depend on the matching typed SDK version pinned in
`package.json`.

The pinned SDK revision is `polymorfa/sdks@94bfd09825817f842434e206e6a77adb5cc0c414`
(the Calls client ships in the same package as `@polymorfa/sdk/calls`).
Its coverage ledger is reconciled against the API contracts at
## SDK dependency

The CLI installs `@polymorfa/sdk` from npm. Each merge to the `dev` branch of
`polymorfa/sdks` publishes a prerelease under the `dev` dist-tag, versioned
`0.1.0-dev.<UTC timestamp YYYYMMDDHHmmss>` (for example
`0.1.0-dev.20260919094454`). Later builds always sort higher. See
`docs/releasing.md` in `polymorfa/sdks`.

Pin one exact version, never a range or the `dev` tag, so every install and
published CLI resolves the same SDK build:

```shell
npm view @polymorfa/sdk dist-tags # find the newest dev build
npm install --save-exact @polymorfa/sdk@0.1.0-dev.<timestamp>
npm ci && npm run verify
```

Git-commit pins (`github:polymorfa/sdks#<sha>`) no longer work. The SDK lives
in `packages/typescript` of a private workspace root, and npm installs the
repository root.

The CLI pins `0.1.0-dev.20260922102711`, the first build published from npm
with provenance. The last git pin was
`polymorfa/sdks@94bfd09825817f842434e206e6a77adb5cc0c414`.

## Coordinated SDK and CLI development

During coordinated SDK and CLI development, verify both worktrees without
replacing the pinned dependency in either manifest. The Calls client ships in
the same package as `@polymorfa/sdk/calls`. The SDK coverage ledger at the last
git pin is reconciled against the API contracts at
`polymorfa/polymorfa@aca849cda44ad8582d7ae87489404d2483173a53` (Platform
snapshot SHA-256
`2a30e8e0af8902703609f8ed0f5bb2c108b39c5c11863d5ebd8907e56a9f25d2`).
Expand All @@ -29,8 +56,8 @@ npx vitest run test/commands/bansafe.test.ts
npm run build
```

Before publishing the CLI, confirm that the pinned SDK revision remains
available, run `npm ci`, and run `npm run verify` from a fresh checkout. A
Before publishing the CLI, confirm that the pinned SDK version is published
(`npm view @polymorfa/sdk@<version> version`), run `npm ci`, and run `npm run verify` from a fresh checkout. A
local-path install is integration evidence only; it is not a publishable
dependency.

Expand Down
17 changes: 4 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"@modelcontextprotocol/server": "2.0.0",
"@napi-rs/keyring": "1.3.0",
"@oclif/core": "^4.13.5",
"@polymorfa/sdk": "github:polymorfa/sdks#94bfd09825817f842434e206e6a77adb5cc0c414",
"@polymorfa/sdk": "0.1.0-dev.20260922102711",
"jq-wasm": "3.0.0-jq-1.8.2",
"zod": "4.2.0"
},
Expand Down
4 changes: 3 additions & 1 deletion src/commands/operation/wait.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export default class OperationWait extends BaseCommand {

async run(): Promise<void> {
const {args, flags} = await this.parse(OperationWait)
const session = await this.developerSession(flags)
// --timeout here is the wait budget in seconds. It replaces the global
// per-request timeout (milliseconds), so it must not reach the client.
const session = await this.developerSession({...flags, timeout: undefined})
const response = await operationsFor(session).wait(args.operation, {
maxWaitMs: flags.timeout * 1000,
requestOptions: session.requestOptions,
Expand Down
54 changes: 51 additions & 3 deletions src/commands/operation/watch.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import {Args} from '@oclif/core'

import {BaseCommand} from '../../base-command.js'
import {operationsFor} from '../../developer/operations.js'
import type {ApiResponse, RequestOptions} from '@polymorfa/sdk'

import {
operationsFor,
type OperationRecord,
type OperationsClient,
} from '../../developer/operations.js'
import {developerResponse} from '../../developer/output.js'
import {CliConditionError} from '../../errors/conditions.js'
import {
Expand All @@ -22,7 +28,9 @@ export default class OperationWatch extends BaseCommand {

async run(): Promise<void> {
const {args, flags} = await this.parse(OperationWatch)
const session = await this.developerSession(flags)
// --timeout here is the wait budget in seconds. It replaces the global
// per-request timeout (milliseconds), so it must not reach the client.
const session = await this.developerSession({...flags, timeout: undefined})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound the initial watch read by the wait deadline

When operation watch --timeout N encounters a slow or unresponsive initial status request, clearing the client's request timeout leaves the plain operations.get() call without the N-second budget; the deadline is checked only after that call resolves, while only subsequent operations.wait() calls receive maxWaitMs. Consequently even --timeout 0 can wait until the SDK's default request timeout, and a hung first read can prevent the documented timeout exit entirely. Apply the remaining deadline to the initial read as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid. Fixed in 8e356d8: operation watch now aborts the first read at the --timeout budget (the SDK wait also allows at least one second) and exits 8. The new test stops watch at --timeout when the first read hangs covers it.

const deadline = Date.now() + flags.timeout * 1000
let afterSequence: number | undefined
for (;;) {
Expand All @@ -31,7 +39,13 @@ export default class OperationWatch extends BaseCommand {
// for the next transition.
const response =
afterSequence === undefined
? await operations.get(args.operation, {}, session.requestOptions)
? await this.firstRead(
operations,
args.operation,
session.requestOptions,
deadline,
flags.timeout,
)
: await operations.wait(args.operation, {
afterSequence,
maxWaitMs: Math.max(0, deadline - Date.now()),
Expand All @@ -58,4 +72,38 @@ export default class OperationWatch extends BaseCommand {
}
}
}

/**
* The first read reports the current state. It gets the same budget as
* --timeout (at least one second to answer), so a server that never
* replies cannot hold the watch past its deadline.
*/
private async firstRead(
operations: OperationsClient,
operationId: string,
requestOptions: RequestOptions,
deadline: number,
timeout: number,
): Promise<ApiResponse<OperationRecord>> {
const budget = new AbortController()
const timer = setTimeout(
() => budget.abort(),
Math.max(deadline - Date.now(), 1000),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
)
try {
return await operations.get(
operationId,
{},
{...requestOptions, signal: budget.signal},
)
} catch (error) {
if (!budget.signal.aborted) throw error
throw new CliConditionError(
'action_required',
`Operation ${operationId} did not report its state within ${timeout} seconds.`,
)
} finally {
clearTimeout(timer)
}
}
}
Loading