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
47 changes: 32 additions & 15 deletions packages/boxel-cli/src/commands/file/read.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import type { Command } from 'commander';
import {
getProfileManager,
NO_ACTIVE_PROFILE_ERROR,
type ProfileManager,
} from '../../lib/profile-manager.ts';
import type { ProfileManager } from '../../lib/profile-manager.ts';
import { resolveRealmAuthenticator } from '../../lib/auth-resolver.ts';
import { resolveRealmSecretSeed } from '../../lib/prompt.ts';
import type { RealmAuthenticator } from '../../lib/realm-authenticator.ts';
import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type';
import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type';
Expand All @@ -26,11 +25,16 @@ export interface ReadResult {

export interface ReadCommandOptions {
profileManager?: ProfileManager;
/** Pre-resolved realm secret seed for administrative (seed) auth. */
realmSecretSeed?: string;
/** @internal Test hook: supply an already-constructed authenticator. */
authenticator?: RealmAuthenticator;
}

interface ReadCliOptions {
realm: string;
json?: boolean;
realmSecretSeed?: boolean;
}

/**
Expand All @@ -39,27 +43,31 @@ interface ReadCliOptions {
* per `isBinaryFilename`). Callers should parse the content themselves
* if needed (e.g. JSON).
*
* Uses the per-realm JWT via `ProfileManager.authedRealmFetch`.
* Auth is resolved via `resolveRealmAuthenticator`: a realm secret seed (when
* supplied) mints a JWT locally as the realm-server bot; otherwise the active
* Matrix profile's per-realm JWT is used.
*/
export async function read(
realmUrl: string,
path: string,
options?: ReadCommandOptions,
): Promise<ReadResult> {
let pm = options?.profileManager ?? getProfileManager();
let active = pm.getActiveProfile();
if (!active) {
return {
ok: false,
error: NO_ACTIVE_PROFILE_ERROR,
};
let resolution = resolveRealmAuthenticator({
realmUrl,
realmSecretSeed: options?.realmSecretSeed,
profileManager: options?.profileManager,
authenticator: options?.authenticator,
});
if (!resolution.ok) {
return { ok: false, error: resolution.error };
}
let authenticator = resolution.authenticator;

let url = new URL(path, ensureTrailingSlash(realmUrl)).href;

let response: Response;
try {
response = await pm.authedRealmFetch(url, {
response = await authenticator.authedRealmFetch(url, {
method: 'GET',
headers: { Accept: SupportedMimeType.CardSource },
});
Expand Down Expand Up @@ -97,11 +105,20 @@ export function registerReadCommand(parent: Command): void {
'Realm-relative file path (e.g., hello-world.json, Cards/my-card.gts)',
)
.requiredOption('--realm <realm-url>', 'The realm URL to read from')
.option(
'--realm-secret-seed',
'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)',
)
.option('--json', 'Output raw JSON response')
.action(async (filePath: string, opts: ReadCliOptions) => {
let result: ReadResult;
try {
result = await read(opts.realm, filePath);
// Inside the try so a seed-resolution throw (e.g. --realm-secret-seed
// with non-TTY stdin) surfaces as a clean error, not an unhandled one.
let realmSecretSeed = await resolveRealmSecretSeed(
opts.realmSecretSeed === true,
);
result = await read(opts.realm, filePath, { realmSecretSeed });
} catch (err) {
console.error(
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
Expand Down
59 changes: 44 additions & 15 deletions packages/boxel-cli/src/commands/file/write.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type { Command } from 'commander';
import { readFileSync } from 'fs';
import {
getProfileManager,
NO_ACTIVE_PROFILE_ERROR,
type ProfileManager,
} from '../../lib/profile-manager.ts';
import type { ProfileManager } from '../../lib/profile-manager.ts';
import { resolveRealmAuthenticator } from '../../lib/auth-resolver.ts';
import { resolveRealmSecretSeed } from '../../lib/prompt.ts';
import type { RealmAuthenticator } from '../../lib/realm-authenticator.ts';
import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
import { SupportedMimeType } from '@cardstack/runtime-common/supported-mime-type';
import { isBinaryFilename } from '@cardstack/runtime-common/infer-content-type';
Expand All @@ -18,12 +17,17 @@ export interface WriteResult {

export interface WriteCommandOptions {
profileManager?: ProfileManager;
/** Pre-resolved realm secret seed for administrative (seed) auth. */
realmSecretSeed?: string;
/** @internal Test hook: supply an already-constructed authenticator. */
authenticator?: RealmAuthenticator;
}

interface WriteCliOptions {
realm: string;
file?: string;
json?: boolean;
realmSecretSeed?: boolean;
}

/**
Expand All @@ -34,22 +38,26 @@ interface WriteCliOptions {
* including the `Buffer` subclass) is sent with `application/octet-stream`,
* which the realm-server routes to `upsertBinaryFile` and writes verbatim.
*
* Uses the per-realm JWT via `ProfileManager.authedRealmFetch`.
* Auth is resolved via `resolveRealmAuthenticator`: a realm secret seed (when
* supplied) mints a JWT locally as the realm-server bot; otherwise the active
* Matrix profile's per-realm JWT is used.
*/
export async function write(
realmUrl: string,
path: string,
content: string | Uint8Array,
options?: WriteCommandOptions,
): Promise<WriteResult> {
let pm = options?.profileManager ?? getProfileManager();
let active = pm.getActiveProfile();
if (!active) {
return {
ok: false,
error: NO_ACTIVE_PROFILE_ERROR,
};
let resolution = resolveRealmAuthenticator({
realmUrl,
realmSecretSeed: options?.realmSecretSeed,
profileManager: options?.profileManager,
authenticator: options?.authenticator,
});
if (!resolution.ok) {
return { ok: false, error: resolution.error };
}
let authenticator = resolution.authenticator;

let url = new URL(path, ensureTrailingSlash(realmUrl)).href;
let isBinary = typeof content !== 'string';
Expand All @@ -72,7 +80,7 @@ export async function write(
}

try {
let response = await pm.authedRealmFetch(url, {
let response = await authenticator.authedRealmFetch(url, {
method: 'POST',
headers: isBinary
? { 'Content-Type': SupportedMimeType.OctetStream }
Expand Down Expand Up @@ -129,8 +137,27 @@ export function registerWriteCommand(parent: Command): void {
'--file <filepath>',
'Read content from a local file instead of STDIN',
)
.option(
'--realm-secret-seed',
'Administrative auth: prompt for a realm secret seed and mint a JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)',
)
.option('--json', 'Output raw JSON response')
.action(async (filePath: string, opts: WriteCliOptions) => {
// Resolve the seed before consuming stdin: when content arrives on stdin
// and --realm-secret-seed prompts, both would contend for stdin. Wrapped
// so a seed-resolution throw (e.g. non-TTY stdin) is a clean error.
let realmSecretSeed: string | undefined;
try {
realmSecretSeed = await resolveRealmSecretSeed(
opts.realmSecretSeed === true,
);
} catch (err) {
stderr(
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}

let content: string | Uint8Array;
if (opts.file) {
// Refuse a source/destination binary-classification mismatch
Expand Down Expand Up @@ -175,7 +202,9 @@ export function registerWriteCommand(parent: Command): void {

let result: WriteResult;
try {
result = await write(opts.realm, filePath, content);
result = await write(opts.realm, filePath, content, {
realmSecretSeed,
});
} catch (err) {
stderr(
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
Expand Down
57 changes: 50 additions & 7 deletions packages/boxel-cli/src/commands/realm/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import {
getProfileManager,
type ProfileManager,
} from '../../lib/profile-manager.ts';
import {
deriveOwnerUserId,
deriveRealmServerUrl,
} from '../../lib/seed-auth.ts';
import { resolveRealmSecretSeed } from '../../lib/prompt.ts';
import { unpublishRealm } from './unpublish.ts';
import { cliLog } from '../../lib/cli-log.ts';
import { FG_CYAN, FG_GREEN, FG_RED, RESET } from '../../lib/colors.ts';
Expand All @@ -35,6 +40,13 @@ export interface PublishOptions {
*/
force?: boolean;
profileManager?: ProfileManager;
/** Seed-mode admin auth — mints an owner-scoped realm-server token. */
realmSecretSeed?: string;
/**
* Owner Matrix id for the seed-minted server token. Defaults to the owner
* derived from the source realm URL (`@<owner>:<domain>`).
*/
asUser?: string;
}

export interface PublishRealmResult {
Expand All @@ -61,12 +73,27 @@ export async function publishRealm(
publishedRealmURL: string,
options: PublishOptions = {},
): Promise<PublishRealmResult> {
let pm = options.profileManager ?? getProfileManager();
let client = buildCliRealmClient(pm);

let normalizedSource = ensureTrailingSlash(sourceRealmURL);
let normalizedPublished = ensureTrailingSlash(publishedRealmURL);

// Seed mode mints an owner-scoped realm-server token; the owner defaults to
// the one derived from the source realm URL. `pm` stays defined for the
// profile path (and threads into the conflict-retry unpublish below).
let realmServerURL = deriveRealmServerUrl(normalizedSource);
let asUser =
options.asUser ??
(options.realmSecretSeed ? deriveOwnerUserId(normalizedSource) : undefined);
let pm = options.realmSecretSeed
? undefined
: (options.profileManager ?? getProfileManager());
let client = options.realmSecretSeed
? buildCliRealmClient({
realmSecretSeed: options.realmSecretSeed,
realmServerURL,
asUser: asUser!,
})
: buildCliRealmClient(pm!);

// Pre-publish gate: refuse to publish a realm with private-dependency or
// error-document violations (which would break the published site) unless
// the caller forces it.
Expand Down Expand Up @@ -101,6 +128,9 @@ export async function publishRealm(
let unpublishResult = await unpublishRealm(normalizedPublished, {
profileManager: pm,
tolerateMissing: true,
realmSecretSeed: options.realmSecretSeed,
realmServerURL,
asUser,
});
if (!unpublishResult.unpublished && !unpublishResult.notFound) {
throw new Error(
Expand Down Expand Up @@ -144,6 +174,8 @@ export interface PublishCliOptions {
republish?: boolean;
force?: boolean;
json?: boolean;
realmSecretSeed?: boolean;
asUser?: string;
}

export function publishCliOptsToOptions(
Expand Down Expand Up @@ -182,6 +214,14 @@ export function registerPublishCommand(realm: Command): void {
'--force',
'Publish even if the realm has publishability violations (skips the gate)',
)
.option(
'--realm-secret-seed',
'Administrative auth: prompt for a realm secret seed and mint an owner-scoped JWT locally instead of using a Matrix profile (env: BOXEL_REALM_SECRET_SEED)',
)
.option(
'--as-user <matrix-id>',
'Owner Matrix id to authorize as in seed mode (defaults to the owner derived from the source realm URL)',
)
.option('--json', 'Output the result as JSON')
.action(
async (
Expand All @@ -190,11 +230,14 @@ export function registerPublishCommand(realm: Command): void {
opts: PublishCliOptions,
) => {
try {
let result = await publishRealm(
sourceRealmURL,
publishedRealmURL,
publishCliOptsToOptions(opts),
let realmSecretSeed = await resolveRealmSecretSeed(
opts.realmSecretSeed === true,
);
let result = await publishRealm(sourceRealmURL, publishedRealmURL, {
...publishCliOptsToOptions(opts),
realmSecretSeed,
asUser: opts.asUser,
});
if (opts.json) {
cliLog.output(JSON.stringify(result, null, 2));
} else {
Expand Down
Loading
Loading