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
143 changes: 77 additions & 66 deletions README.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/__snapshots__/program.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ Options:

Commands:
create [options] Scaffold a new Kizen plugin project
setup-claude [options] Install or refresh the Claude Code skill for building
custom blocks
setup-claude [options] Install or refresh the Claude Code files in a plugin,
or with --mcp only register the Kizen MCP server
setup-copilot [options] Add or refresh the Copilot code-review setup in an
existing plugin repo
build Bundle the plugin app into .kizenapp directory
Expand Down
17 changes: 15 additions & 2 deletions src/commands/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,21 @@ export function adminCommand(program: Command): void {
const admin = new RawArgsCommand(ADMIN)
.copyInheritedSettings(program)
.description("Run Kizen's admin CLI (kizen) with the signed-in session")
.usage('[--env <env>] [--] <kizen args...>\n appbuilder admin init [--force]')
.option('--env <env>', 'environment to use (default: the only signed-in one)')
.usage(
'[--env <env>] [--business <id-or-name> [--user <userId>]] [--] <kizen args...>\n appbuilder admin init [--force]',
)
.option(
'--env <env>',
"environment to use (default: the only signed-in one, or with --business the only one where it's saved)",
)
.option(
'--business <id-or-name>',
'use this saved account, by business id or name (ignoring case), instead of the current one (default: $APPBUILDER_BUSINESS)',
)
.option(
'--user <userId>',
'with --business, use the saved account of this user id (default: $APPBUILDER_USER)',
)
.allowExcessArguments()
.addHelpText(
'after',
Expand Down
17 changes: 12 additions & 5 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,17 @@ const AUTH_COMMANDS: AuthCommandSpec[] = [
{
name: 'whoami',
description: 'Show the user and business of the stored session',
env: 'environment to check (default: the only signed-in one)',
env: "environment to check (default: the only signed-in one, or with --business the only one where it's saved)",
options: [
[
'--business <id-or-name>',
'check this saved account, by business id or name (ignoring case), instead of the current one (default: $APPBUILDER_BUSINESS)',
],
[
'--user <userId>',
'with --business, check the saved account of this user id (default: $APPBUILDER_USER)',
],
],
run: runWhoami,
},
{
Expand All @@ -63,10 +73,7 @@ const AUTH_COMMANDS: AuthCommandSpec[] = [
'Print an Authorization header for the stored session as JSON (for a Claude Code MCP headersHelper)',
env: 'environment to use; must match CLAUDE_CODE_MCP_SERVER_URL when that is set (default: the environment matching it, else the only signed-in one)',
options: [
[
'--business <id>',
"with --user, use only this saved account, and fail if it isn't the environment's current one",
],
['--business <id>', 'with --user, use this saved account, current or not'],
['--user <userId>', 'with --business, the user id of that account'],
],
run: runAuthHeader,
Expand Down
16 changes: 16 additions & 0 deletions src/commands/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ export function blockCommand(program: Command): void {
.option('--forget', 'clear the remembered target for this block first')
.option('--json', 'print a JSON result to stdout (always headless)')
.option('--env <env>', 'environment of the `appbuilder login` session to use')
.option(
'--business <id-or-name>',
'use this saved account, by business id or name (ignoring case), instead of the current one (default: $APPBUILDER_BUSINESS)',
)
.option(
'--user <userId>',
'with --business, use the saved account of this user id (default: $APPBUILDER_USER)',
)
.action(async (apiName: string | undefined, options: PushCommandOptions) => {
try {
const deps = createDefaultPushDeps(process.cwd());
Expand Down Expand Up @@ -124,6 +132,14 @@ export function blockCommand(program: Command): void {
.option('--object <id>', 'list the chart groups of this custom object')
.option('--json', 'print a JSON result to stdout')
.option('--env <env>', 'environment of the `appbuilder login` session to use')
.option(
'--business <id-or-name>',
'use this saved account, by business id or name (ignoring case), instead of the current one (default: $APPBUILDER_BUSINESS)',
)
.option(
'--user <userId>',
'with --business, use the saved account of this user id (default: $APPBUILDER_USER)',
)
.action(async (options: TargetsCommandOptions) => {
try {
await runTargetsHeadless(options, createDefaultPushDeps(process.cwd()));
Expand Down
9 changes: 7 additions & 2 deletions src/commands/setupClaude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ import {
export function setupClaudeCommand(program: Command, version: string): void {
program
.command('setup-claude')
.description('Install or refresh the Claude Code skill for building custom blocks')
.description(
'Install or refresh the Claude Code files in a plugin, or with --mcp only register the Kizen MCP server',
)
.option('--dry-run', 'report what would change without writing files')
.option(
'--include-lib',
'also install the Kizen data helper library (<entry>/lib/kizenData.js)',
)
.option('--mcp', 'also register the Kizen MCP server in Claude Code (user scope)')
.option(
'--mcp',
'only register the Kizen MCP server in Claude Code (user scope); writes no files, so it works in any directory',
)
.option('--env <env>', 'environment of the MCP server to register (with --mcp; default: go)')
.option('--force', 'replace an existing Kizen MCP server registration (with --mcp)')
.action(async (options: SetupClaudeOptions) => {
Expand Down
125 changes: 124 additions & 1 deletion src/lib/authSelection.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
accountRequest,
pinnedProfileNotice,
selectAuth,
type AccountFlags,
type AccountRequestResult,
type AuthFlags,
type AuthSelectionResult,
} from './authSelection.js';
Expand Down Expand Up @@ -38,7 +41,127 @@ describe('selectAuth', () => {
},
],
])('%s', (_label, flags, expected) => {
expect(selectAuth(flags)).toStrictEqual(expected);
expect(selectAuth(flags, {})).toStrictEqual(expected);
});
});

describe('selectAuth and a named saved account', () => {
it.each<[string, AuthFlags, Record<string, string>, AuthSelectionResult]>([
[
'--business with --env selects that saved account in that session',
{ env: 'staging', business: 'Acme', user: 'u1' },
{},
{
ok: true,
selection: {
kind: 'oauth',
env: 'staging',
account: { business: 'Acme', user: 'u1', source: 'flags' },
},
},
],
[
'APPBUILDER_BUSINESS selects that saved account',
{},
{ APPBUILDER_BUSINESS: 'Acme' },
{
ok: true,
selection: {
kind: 'oauth',
account: { business: 'Acme', user: undefined, source: 'env' },
},
},
],
[
'--business with --profile is a usage error',
{ profile: 'Demo', business: 'Acme' },
{},
{
ok: false,
message:
"--business selects a saved sign-in account, so it can't be combined with -c or --profile.",
},
],
[
'--user with -c is a usage error',
{ credentials: '/tmp/c.json', user: 'u1' },
{},
{
ok: false,
message:
"--user selects a saved sign-in account, so it can't be combined with -c or --profile.",
},
],
[
'-c ignores APPBUILDER_BUSINESS and APPBUILDER_USER',
{ credentials: '/tmp/c.json' },
{ APPBUILDER_BUSINESS: 'Acme', APPBUILDER_USER: 'u1' },
{ ok: true, selection: { kind: 'api-key', credentials: '/tmp/c.json' } },
],
[
'--profile ignores a lone APPBUILDER_USER',
{ profile: 'Demo' },
{ APPBUILDER_USER: 'u1' },
{ ok: true, selection: { kind: 'api-key', profile: 'Demo' } },
],
[
'the session with a lone APPBUILDER_USER is a usage error',
{},
{ APPBUILDER_USER: 'u1' },
{ ok: false, message: 'APPBUILDER_USER only applies with APPBUILDER_BUSINESS.' },
],
])('%s', (_label, flags, processEnv, expected) => {
expect(selectAuth(flags, processEnv)).toStrictEqual(expected);
});
});

describe('accountRequest', () => {
it.each<[string, AccountFlags, Record<string, string>, AccountRequestResult]>([
['nothing names an account: the current one', {}, {}, { ok: true, request: undefined }],
[
'--business alone',
{ business: 'Acme' },
{},
{ ok: true, request: { business: 'Acme', user: undefined, source: 'flags' } },
],
[
'the flags replace the variables as a pair',
{ business: 'Acme' },
{ APPBUILDER_BUSINESS: 'Other', APPBUILDER_USER: 'u9' },
{ ok: true, request: { business: 'Acme', user: undefined, source: 'flags' } },
],
[
'the variables when no flag is passed',
{},
{ APPBUILDER_BUSINESS: 'Other', APPBUILDER_USER: 'u9' },
{ ok: true, request: { business: 'Other', user: 'u9', source: 'env' } },
],
[
'an empty APPBUILDER_BUSINESS is unset',
{},
{ APPBUILDER_BUSINESS: '' },
{ ok: true, request: undefined },
],
[
'an empty APPBUILDER_USER is unset',
{},
{ APPBUILDER_BUSINESS: 'Other', APPBUILDER_USER: '' },
{ ok: true, request: { business: 'Other', user: undefined, source: 'env' } },
],
[
'--user without --business',
{ user: 'u1' },
{ APPBUILDER_BUSINESS: 'Other' },
{ ok: false, message: '--user only applies with --business.' },
],
[
'APPBUILDER_USER without APPBUILDER_BUSINESS',
{},
{ APPBUILDER_BUSINESS: '', APPBUILDER_USER: 'u1' },
{ ok: false, message: 'APPBUILDER_USER only applies with APPBUILDER_BUSINESS.' },
],
])('%s', (_label, flags, processEnv, expected) => {
expect(accountRequest(flags, processEnv)).toStrictEqual(expected);
});
});

Expand Down
87 changes: 83 additions & 4 deletions src/lib/authSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,72 @@

import { quoteArg } from './shellArg.js';

export interface AuthFlags {
/** `--business` / `--user`: a saved account to use instead of the current one. */
export interface AccountFlags {
business?: string;
user?: string;
}

export interface AuthFlags extends AccountFlags {
credentials?: string;
profile?: string;
env?: string;
}

/** The environment variables that name a saved account when the flags don't. */
export const BUSINESS_ENV = 'APPBUILDER_BUSINESS';
export const USER_ENV = 'APPBUILDER_USER';

/**
* A saved account a command names, by business id or name and optionally user id. `source` says
* where it came from, so an error can name what to change.
*/
export interface AccountRequest {
business: string;
user?: string | undefined;
source?: 'flags' | 'env' | undefined;
}

export type AccountRequestResult =
| { ok: true; request: AccountRequest | undefined }
| { ok: false; message: string };

/**
* The saved account a command names: `--business` (and `--user`), else `APPBUILDER_BUSINESS`
* (and `APPBUILDER_USER`), else undefined for the current account. The flags replace the
* variables as a pair, and a user id without a business is a usage error. An empty variable
* counts as unset.
*/
export function accountRequest(
flags: AccountFlags,
processEnv: Readonly<Record<string, string | undefined>>,
): AccountRequestResult {
if (flags.business !== undefined) {
return { ok: true, request: { business: flags.business, user: flags.user, source: 'flags' } };
}

if (flags.user !== undefined) {
return { ok: false, message: '--user only applies with --business.' };
}

const business = processEnv[BUSINESS_ENV] || undefined;
const user = processEnv[USER_ENV] || undefined;

if (business === undefined) {
return user === undefined
? { ok: true, request: undefined }
: { ok: false, message: `${USER_ENV} only applies with ${BUSINESS_ENV}.` };
}

return { ok: true, request: { business, user, source: 'env' } };
}

export type AuthSelection =
/** Exactly one of the two is set; `-c` wins when both were passed. */
| { kind: 'api-key'; credentials: string; profile?: never }
| { kind: 'api-key'; profile: string; credentials?: never }
| { kind: 'oauth'; env?: string };
/** `account` names a saved account; without it, the current one. */
| { kind: 'oauth'; env?: string; account?: AccountRequest };

/** `ok: false` is a usage error. */
export type AuthSelectionResult =
Expand All @@ -26,7 +81,14 @@ export type AuthSelectionResult =
const API_KEY_FLAGS_HINT =
'To use an API key instead (deprecated), pass --profile <name> or -c <path>.';

export function selectAuth(flags: AuthFlags): AuthSelectionResult {
/**
* `processEnv` supplies `APPBUILDER_BUSINESS` / `APPBUILDER_USER` for the sign-in session; an API
* key ignores them.
*/
export function selectAuth(
flags: AuthFlags,
processEnv: Readonly<Record<string, string | undefined>>,
): AuthSelectionResult {
const apiKey = flags.credentials !== undefined || flags.profile !== undefined;

if (apiKey && flags.env !== undefined) {
Expand All @@ -36,6 +98,13 @@ export function selectAuth(flags: AuthFlags): AuthSelectionResult {
};
}

if (apiKey && (flags.business !== undefined || flags.user !== undefined)) {
return {
ok: false,
message: `${flags.business === undefined ? '--user' : '--business'} selects a saved sign-in account, so it can't be combined with -c or --profile.`,
};
}

if (flags.credentials !== undefined) {
return { ok: true, selection: { kind: 'api-key', credentials: flags.credentials } };
}
Expand All @@ -44,9 +113,19 @@ export function selectAuth(flags: AuthFlags): AuthSelectionResult {
return { ok: true, selection: { kind: 'api-key', profile: flags.profile } };
}

const account = accountRequest(flags, processEnv);

if (!account.ok) {
return account;
}

return {
ok: true,
selection: flags.env === undefined ? { kind: 'oauth' } : { kind: 'oauth', env: flags.env },
selection: {
kind: 'oauth',
...(flags.env !== undefined && { env: flags.env }),
...(account.request !== undefined && { account: account.request }),
},
};
}

Expand Down
Loading
Loading