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
29 changes: 23 additions & 6 deletions docs/guide/project-collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ void project team invite teammate@example.com --role collaborator
Pass `--project <slug>` to manage a project other than the linked project. An unknown email is rejected. A project invitation neither creates an account nor adds the address to a restricted-signup allowlist.

Invitations expire after seven days. If the platform cannot send invitation
email, the invitation still appears when the invited user runs
`void project team pending`.
email, the CLI prints the invitation ID and connection and acceptance commands
to share with the invited user. The invitation also appears when that user runs
`void project team pending` on the same platform.

List the roster and sent invitations with:

Expand All @@ -49,9 +50,11 @@ void project team invitations

## Respond to an Invitation

The invited user can see invitations addressed to their account and respond using the invitation ID:
Connect to the platform from your invitation, then see invitations addressed to
your account and respond using the invitation ID:

```sh
void connect https://api.example.com
void project team pending
void project team accept <invitation-id>
# or
Expand All @@ -60,9 +63,23 @@ void project team decline <invitation-id>

An invitation is bound to the registered account. Forwarding its ID does not let another user accept it.

These three invitation-response commands are account-scoped and do not depend
on the current project's deploy target. They can run from a direct Cloudflare
project directory when it resolves your connected Void platform account.
These three commands use the active platform connection selected by
`void connect`, even inside a directory linked to another project or deployed
directly to Cloudflare. `VOID_API_URL` takes precedence when set. If you use
`VOID_TOKEN`, set `VOID_API_URL` to the matching platform; a token without that
URL selects Void Cloud. Each command shows the platform it contacts.

Accepting an invitation grants access without linking your current directory.
Open the invited application's directory and run `void project link` if it is
not linked yet. If it is already linked to another project, use a separate
checkout without `.void/project.json`, connect to the invited platform there,
and run `void project link`.

If the invitation is missing, check the platform and signed-in account. To
switch accounts, set `VOID_API_URL` to the invitation's platform URL, unset
`VOID_TOKEN` if present, then run `void auth logout` and `void auth login` using
the invited email address. For an expired or revoked invitation, ask a project
administrator to invite you again.

## Change or Remove Access

Expand Down
8 changes: 6 additions & 2 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,12 @@ Readers can view the project but cannot deploy. Collaborators can deploy and man

Project-scoped team management commands are not available for projects deployed
directly to Cloudflare. The account-scoped `pending`, `accept`, and `decline`
commands do not depend on the project deploy target and can be run from a direct
Cloudflare project directory that resolves the connected Void platform account.
commands use the active connection selected by `void connect <url>`, regardless
of the current project's link or deploy target. `VOID_API_URL` takes precedence;
an unscoped `VOID_TOKEN` selects Void Cloud. These commands display their
platform, and accepting an invitation leaves directory links intact. To link
the invited application, run `void project link` in an unlinked checkout of
that application.

### `void project token <create|list|renew|revoke>`

Expand Down
2 changes: 1 addition & 1 deletion packages/void/skills/void/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ mutation failure before retrying.

Use `void connect` for deployment onboarding: no arguments offers Cloudflare or a Void platform, `--platform cloudflare` signs in and selects an account, and a platform URL verifies discovery and signs in with a supported provider. It preserves existing project links when connecting elsewhere. `--no-login` saves only verified Void connection metadata; authenticated headless connection requires a valid origin-scoped keychain session or `VOID_TOKEN` with matching `VOID_API_URL`. Platform installation and administration use `void platform`.

For Void platform project access, use `void project team`: invite only an email already registered on that platform, assign `reader`, `collaborator`, or `admin`, and use the account-scoped pending/accept/decline commands for the invited account. Project-scoped team management does not apply to direct Cloudflare deployments, but invitation responses do not depend on the project deploy target and can run from a direct Cloudflare project directory that resolves the connected Void platform account. Installation administrators transfer ownership with `void platform project owner <project-id> <user-id> --plan` and apply the reviewed transfer with `--yes`.
For Void platform project access, use `void project team`: invite only an email already registered on that platform and assign `reader`, `collaborator`, or `admin`. The invited account uses `void connect <url>` followed by pending/accept/decline on that active connection, regardless of the current directory's project link or deploy target. `VOID_API_URL` overrides the connection; an unscoped `VOID_TOKEN` selects Void Cloud. Acceptance preserves directory links; run `void project link` in an unlinked checkout of the invited application. Project-scoped team management does not apply to direct Cloudflare deployments. Installation administrators transfer ownership with `void platform project owner <project-id> <user-id> --plan` and apply the reviewed transfer with `--yes`.

Use Void commands for every user-facing workflow. Never ask the user to install, authenticate, or run Wrangler directly. Say Cloudflare or Void instead, except when naming literal `wrangler.jsonc` / `wrangler.json` files or `WRANGLER_*` environment variables the user must inspect.

Expand Down
50 changes: 46 additions & 4 deletions packages/void/src/cli/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,42 @@ export function getToken(root: string = process.cwd()): string | null {
}
return null;
}
const config = readConfig();
return readTokenForOrigin(apiUrl, config, legacyCredentialAccount(root, apiUrl, config));
}

/** Account actions follow the selected connection, independently of application metadata. */
export function getAccountCredential(): { apiUrl: string; token: string | null } {
const config = readConfig();
const apiUrl = normalizePlatformApiUrl(
process.env.VOID_API_URL ||
(process.env.VOID_TOKEN
? DEFAULT_API_URL
: (getActivePlatformConnection()?.apiUrl ??
config.api ??
readLegacyHostedPlatformSelection()?.apiUrl ??
(keyringGet('token')
? DEFAULT_API_URL
: keyringGet('stagingToken')
? STAGING_API_URL
: DEFAULT_API_URL))),
);
return {
apiUrl,
token: readTokenForOrigin(
apiUrl,
config,
legacyCredentialAccountForMode(apiUrl === STAGING_API_URL, apiUrl, config),
),
};
}

function readTokenForOrigin(
apiUrl: string,
config: Config,
legacyAccount: 'token' | 'stagingToken' | null,
): string | null {
const envToken = process.env.VOID_TOKEN;
if (envToken) {
const tokenOrigin = process.env.VOID_API_URL
? normalizePlatformApiUrl(process.env.VOID_API_URL)
Expand All @@ -146,7 +182,6 @@ export function getToken(root: string = process.cwd()): string | null {
}
return envToken;
}
const config = readConfig();
const normalizedApiUrl = normalizePlatformApiUrl(apiUrl);
const originToken = readOriginScopedToken(normalizedApiUrl, config);
if (originToken) {
Expand All @@ -155,7 +190,6 @@ export function getToken(root: string = process.cwd()): string | null {

const account = platformCredentialAccount(normalizedApiUrl);

const legacyAccount = legacyCredentialAccount(root, normalizedApiUrl, config);
if (legacyAccount) {
const legacyKeyringToken = keyringGet(legacyAccount);
if (legacyKeyringToken) {
Expand Down Expand Up @@ -267,11 +301,19 @@ function legacyCredentialAccount(
normalizedApiUrl: string,
config: Config,
): 'token' | 'stagingToken' | null {
const account = isStagingMode(root) ? 'stagingToken' : 'token';
return legacyCredentialAccountForMode(isStagingMode(root), normalizedApiUrl, config);
}

function legacyCredentialAccountForMode(
staging: boolean,
normalizedApiUrl: string,
config: Config,
): 'token' | 'stagingToken' | null {
const account = staging ? 'stagingToken' : 'token';
let configuredUrl: string;
try {
configuredUrl = normalizePlatformApiUrl(
config.api ?? (isStagingMode(root) ? STAGING_API_URL : DEFAULT_API_URL),
config.api ?? (staging ? STAGING_API_URL : DEFAULT_API_URL),
);
} catch {
return null;
Expand Down
44 changes: 39 additions & 5 deletions packages/void/src/cli/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ import {
PlatformClient,
type DeploymentResponse,
type ManifestOnlyDeployPayload,
type ProjectResponse,
} from './client.ts';
import {
CloudflareAuthenticationError,
Expand Down Expand Up @@ -6489,6 +6490,9 @@ async function runManagedDeploy(

if (requestedProjectSlug) {
const projects = await client.listProjects();
assertProjectDeployAccess(
projects.find((project) => project.slug === requestedProjectSlug),
);
config = resolveProjectBySlug(projects, requestedProjectSlug);

if (!config) {
Expand All @@ -6509,6 +6513,18 @@ async function runManagedDeploy(
}
} else {
config = readProjectConfig(root);
if (config) {
const projects = await client.listProjects();
const { projectId, slug } = config;
const project = projects.find((project) => project.id === projectId);
if (!project) {
throw new Error(
`deploy: The linked project "${slug}" is unavailable or you no longer have access. ` +
'Ask a project admin or owner to restore access, or choose another project with `--project <slug>`.',
);
}
assertProjectDeployAccess(project);
}
}

if (!config && !process.stdin.isTTY) {
Expand Down Expand Up @@ -6666,25 +6682,43 @@ async function runManagedDeploy(
}
}

function assertProjectDeployAccess(project: ProjectResponse | undefined): void {
// Older platforms omit roles; the API remains authoritative for every deploy.
if (project?.role === 'reader') {
throw new Error(
`deploy: You have reader access to "${project.slug}" and cannot deploy. ` +
'Ask a project admin or owner to grant collaborator access, or choose another project with `--project <slug>`.',
);
}
}

export async function promptDeployProjectConfig(
root: string,
client: PlatformClient,
cliLog?: CliLogger,
): Promise<ProjectConfig> {
const projects = await client.listProjects();
const deployableProjects = projects.filter((project) => project.role !== 'reader');
if (deployableProjects.length !== projects.length) {
log.warn(
'Projects with reader access are hidden from deploy selection. Ask a project admin or owner to grant collaborator access to deploy to them.',
);
}
const action = await promptProjectSetupAction('Set up a Void project for deploy:', {
includeLink: projects.length > 0,
includeLink: deployableProjects.length > 0,
});

if (action === 'create') {
return promptAndCreateProject(root, client);
}

// Project names are only shown after the user opts into linking.
const result = await promptProjectSelection(projects, 'Select a project to deploy to:', root, {
confirmExisting: true,
includeCreate: false,
});
const result = await promptProjectSelection(
deployableProjects,
'Select a project to deploy to:',
root,
{ confirmExisting: true, includeCreate: false },
);

if (result.action === 'create') {
throw new Error('Unexpected project selection result.');
Expand Down
6 changes: 3 additions & 3 deletions packages/void/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,15 +541,15 @@ const HELP_TREE: HelpTree = {
),
leave: withProjectOption('Leave a shared project.', 'void project team leave [options]'),
pending: basicSubcommand(
'List project invitations awaiting your response.',
'List invitations on the active platform connection (VOID_API_URL overrides it).',
'void project team pending',
),
accept: basicSubcommand(
'Accept an invitation addressed to your account.',
'Accept your invitation on the active platform connection, preserving project links.',
'void project team accept <invitation-id>',
),
decline: basicSubcommand(
'Decline an invitation addressed to your account.',
'Decline your invitation on the active platform connection (VOID_API_URL overrides it).',
'void project team decline <invitation-id>',
),
},
Expand Down
62 changes: 43 additions & 19 deletions packages/void/src/cli/project-team.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { intro, log, note, outro } from '@clack/prompts';
import { getToken, readPlatformToken } from './auth.ts';
import { getAccountCredential, getToken } from './auth.ts';
import {
PlatformApiError,
PlatformClient,
type ProjectInvitationResponse,
type ProjectMemberResponse,
} from './client.ts';
import { VOID_CLOUD_PLATFORM } from './hosted-platform.ts';
import { cliTitle } from './output.ts';
import { getActivePlatformConnection } from './platform-registry.ts';
import type { ProjectArgs } from './project-args.ts';
import { readDeploymentPlatform, readProjectConfig } from './project.ts';
import { readProjectConfig } from './project.ts';
import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts';

type ProjectTeamArgs = Exclude<Extract<ProjectArgs, { subcommand: 'team' }>, { action: '' }>;
Expand Down Expand Up @@ -74,20 +73,25 @@ async function resolveProjectId(
return linked.projectId;
}

function resolveCredential(
root: string,
accountScoped: boolean,
): { apiUrl?: string; token: string | null } {
if (accountScoped && readDeploymentPlatform(root) === 'cloudflare' && !process.env.VOID_API_URL) {
if (process.env.VOID_TOKEN) {
return { apiUrl: VOID_CLOUD_PLATFORM.apiUrl, token: process.env.VOID_TOKEN };
}
const connection = getActivePlatformConnection();
if (connection) {
return { apiUrl: connection.apiUrl, token: readPlatformToken(connection.apiUrl) };
}
function invitationResponseError(error: unknown, apiUrl: string): unknown {
if (!(error instanceof PlatformApiError)) {
return error;
}
let message: string;
if (error.code === 'project invitation not found') {
message = `Invitation not found on ${apiUrl}. Check VOID_API_URL if set, connect to the platform in your invitation with \`void connect <url>\`, then run \`void project team pending\` to check the invitation ID. If it is missing, ask a project administrator for a new invitation.`;
} else if (error.code === 'forbidden') {
message = `Your account cannot respond to this invitation on ${apiUrl}. Sign in with the account whose registered email received the invitation. To switch accounts, set VOID_API_URL to ${apiUrl}, unset VOID_TOKEN if present, then run \`void auth logout\` and \`void auth login\`. Run \`void project team pending\` to see that account's invitations.`;
} else if (error.code === 'project invitation has expired') {
message =
'This invitation has expired. Ask a project administrator to invite you again, then run `void project team pending`.';
} else if (error.code === 'project invitation is no longer pending') {
message =
'This invitation has already been answered or revoked. Run `void project team pending` to see invitations awaiting your response, or ask a project administrator to check your access.';
} else {
return error;
}
return { token: getToken(root) };
return new Error(message, { cause: error });
}

export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs): Promise<void> {
Expand All @@ -96,7 +100,12 @@ export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs)

const accountScoped =
args.action === 'pending' || args.action === 'accept' || args.action === 'decline';
const { apiUrl, token } = resolveCredential(root, accountScoped);
const { apiUrl, token } = accountScoped
? getAccountCredential()
: { apiUrl: undefined, token: getToken(root) };
if (apiUrl) {
log.info(`Platform: ${apiUrl}`);
}
if (!token) {
log.error(
apiUrl
Expand All @@ -118,7 +127,16 @@ export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs)
return;
}
if (args.action === 'accept' || args.action === 'decline') {
await client.respondToProjectInvitation(args.invitationId, args.action);
try {
await client.respondToProjectInvitation(args.invitationId, args.action);
} catch (error) {
throw invitationResponseError(error, client.apiUrl);
}
if (args.action === 'accept') {
log.info(
'Open the invited application’s directory and run `void project link` if it is not linked yet. If it is linked to another project, use a separate checkout. Accepting an invitation keeps existing project links.',
);
}
outro(`Project invitation ${args.action === 'accept' ? 'accepted' : 'declined'}.`);
return;
}
Expand Down Expand Up @@ -151,6 +169,12 @@ export async function runProjectTeamCommand(root: string, args: ProjectTeamArgs)
? ''
: ` (email delivery: ${result.delivery.status.replace('_', ' ')})`;
const action = result.refreshed ? 'Updated invitation for' : 'Invited';
if (result.delivery.status !== 'sent') {
note(
`Invitation: ${clean(result.invitation.id)}\nShare these steps with the invited user:\n\nvoid connect '${client.apiUrl.replaceAll("'", "'\\''")}'\nvoid project team pending\nvoid project team accept ${clean(result.invitation.id)}`,
'Invitation saved; email was not sent',
);
}
outro(`${action} ${clean(invitationEmail(result.invitation))} as ${args.role}${delivery}.`);
return;
}
Expand Down
7 changes: 5 additions & 2 deletions packages/void/src/cli/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export async function promptProjectSelection(
...projects.map((p) => ({
value: p.id,
label: `${p.slug} (${p.url ?? `${p.slug}.${getSiteDomain(root)}`})`,
...(p.role ? { hint: p.role === 'reader' ? 'reader (read-only)' : p.role } : {}),
})),
];

Expand All @@ -71,8 +72,10 @@ export async function promptProjectSelection(
if (options.confirmExisting) {
const confirmed = await confirm({
message:
`Linking to an existing project. Running \`void deploy\` will overwrite the current deployment of ` +
`${project.url ?? `${project.slug}.${getSiteDomain(root)}`}. Continue?`,
project.role === 'reader'
? `Link to ${project.slug} with read-only access? Deploying requires collaborator access or higher.`
: `Linking to an existing project. Running \`void deploy\` will overwrite the current deployment of ` +
`${project.url ?? `${project.slug}.${getSiteDomain(root)}`}. Continue?`,
});
if (isCancel(confirmed) || !confirmed) {
cancel('Project link cancelled.');
Expand Down
Loading
Loading