@@ -3,6 +3,7 @@ import * as p from "@clack/prompts";
|
|||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||||
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
|
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
|
||||||
|
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||||
import { readConfig, resolveConfigPath } from "../config/store.js";
|
import { readConfig, resolveConfigPath } from "../config/store.js";
|
||||||
|
|
||||||
function hashToken(token: string) {
|
function hashToken(token: string) {
|
||||||
@@ -13,7 +14,8 @@ function createInviteToken() {
|
|||||||
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
|
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDbUrl(configPath?: string) {
|
function resolveDbUrl(configPath?: string, explicitDbUrl?: string) {
|
||||||
|
if (explicitDbUrl) return explicitDbUrl;
|
||||||
const config = readConfig(configPath);
|
const config = readConfig(configPath);
|
||||||
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
||||||
if (config?.database.mode === "postgres" && config.database.connectionString) {
|
if (config?.database.mode === "postgres" && config.database.connectionString) {
|
||||||
@@ -49,8 +51,10 @@ export async function bootstrapCeoInvite(opts: {
|
|||||||
force?: boolean;
|
force?: boolean;
|
||||||
expiresHours?: number;
|
expiresHours?: number;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
dbUrl?: string;
|
||||||
}) {
|
}) {
|
||||||
const configPath = resolveConfigPath(opts.config);
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
loadPaperclipEnvFile(configPath);
|
||||||
const config = readConfig(configPath);
|
const config = readConfig(configPath);
|
||||||
if (!config) {
|
if (!config) {
|
||||||
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
|
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
|
||||||
@@ -62,7 +66,7 @@ export async function bootstrapCeoInvite(opts: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbUrl = resolveDbUrl(configPath);
|
const dbUrl = resolveDbUrl(configPath, opts.dbUrl);
|
||||||
if (!dbUrl) {
|
if (!dbUrl) {
|
||||||
p.log.error(
|
p.log.error(
|
||||||
"Could not resolve database connection for bootstrap.",
|
"Could not resolve database connection for bootstrap.",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
storageCheck,
|
storageCheck,
|
||||||
type CheckResult,
|
type CheckResult,
|
||||||
} from "../checks/index.js";
|
} from "../checks/index.js";
|
||||||
|
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||||
|
|
||||||
const STATUS_ICON = {
|
const STATUS_ICON = {
|
||||||
@@ -31,6 +32,7 @@ export async function doctor(opts: {
|
|||||||
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
|
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
|
||||||
|
|
||||||
const configPath = resolveConfigPath(opts.config);
|
const configPath = resolveConfigPath(opts.config);
|
||||||
|
loadPaperclipEnvFile(configPath);
|
||||||
const results: CheckResult[] = [];
|
const results: CheckResult[] = [];
|
||||||
|
|
||||||
// 1. Config check (must pass before others)
|
// 1. Config check (must pass before others)
|
||||||
|
|||||||
@@ -229,6 +229,10 @@ function quickstartDefaultsFromEnv(): {
|
|||||||
return { defaults, usedEnvKeys, ignoredEnvKeys };
|
return { defaults, usedEnvKeys, ignoredEnvKeys };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "database" | "server">): boolean {
|
||||||
|
return config.server.deploymentMode === "authenticated" && config.database.mode !== "embedded-postgres";
|
||||||
|
}
|
||||||
|
|
||||||
export async function onboard(opts: OnboardOptions): Promise<void> {
|
export async function onboard(opts: OnboardOptions): Promise<void> {
|
||||||
printPaperclipCliBanner();
|
printPaperclipCliBanner();
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
|
p.intro(pc.bgCyan(pc.black(" paperclipai onboard ")));
|
||||||
@@ -450,7 +454,7 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||||||
"Next commands",
|
"Next commands",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (server.deploymentMode === "authenticated") {
|
if (canCreateBootstrapInviteImmediately({ database, server })) {
|
||||||
p.log.step("Generating bootstrap CEO invite");
|
p.log.step("Generating bootstrap CEO invite");
|
||||||
await bootstrapCeoInvite({ config: configPath });
|
await bootstrapCeoInvite({ config: configPath });
|
||||||
}
|
}
|
||||||
@@ -473,5 +477,15 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (server.deploymentMode === "authenticated" && database.mode === "embedded-postgres") {
|
||||||
|
p.log.info(
|
||||||
|
[
|
||||||
|
"Bootstrap CEO invite will be created after the server starts.",
|
||||||
|
`Next: ${pc.cyan("paperclipai run")}`,
|
||||||
|
`Then: ${pc.cyan("paperclipai auth bootstrap-ceo")}`,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
p.outro("You're all set!");
|
p.outro("You're all set!");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import path from "node:path";
|
|||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import * as p from "@clack/prompts";
|
import * as p from "@clack/prompts";
|
||||||
import pc from "picocolors";
|
import pc from "picocolors";
|
||||||
|
import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js";
|
||||||
import { onboard } from "./onboard.js";
|
import { onboard } from "./onboard.js";
|
||||||
import { doctor } from "./doctor.js";
|
import { doctor } from "./doctor.js";
|
||||||
|
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||||
import { configExists, resolveConfigPath } from "../config/store.js";
|
import { configExists, resolveConfigPath } from "../config/store.js";
|
||||||
|
import type { PaperclipConfig } from "../config/schema.js";
|
||||||
|
import { readConfig } from "../config/store.js";
|
||||||
import {
|
import {
|
||||||
describeLocalInstancePaths,
|
describeLocalInstancePaths,
|
||||||
resolvePaperclipHomeDir,
|
resolvePaperclipHomeDir,
|
||||||
@@ -19,6 +23,13 @@ interface RunOptions {
|
|||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StartedServer {
|
||||||
|
apiUrl: string;
|
||||||
|
databaseUrl: string;
|
||||||
|
host: string;
|
||||||
|
listenPort: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runCommand(opts: RunOptions): Promise<void> {
|
export async function runCommand(opts: RunOptions): Promise<void> {
|
||||||
const instanceId = resolvePaperclipInstanceId(opts.instance);
|
const instanceId = resolvePaperclipInstanceId(opts.instance);
|
||||||
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
|
||||||
@@ -31,6 +42,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
|||||||
|
|
||||||
const configPath = resolveConfigPath(opts.config);
|
const configPath = resolveConfigPath(opts.config);
|
||||||
process.env.PAPERCLIP_CONFIG = configPath;
|
process.env.PAPERCLIP_CONFIG = configPath;
|
||||||
|
loadPaperclipEnvFile(configPath);
|
||||||
|
|
||||||
p.intro(pc.bgCyan(pc.black(" paperclipai run ")));
|
p.intro(pc.bgCyan(pc.black(" paperclipai run ")));
|
||||||
p.log.message(pc.dim(`Home: ${paths.homeDir}`));
|
p.log.message(pc.dim(`Home: ${paths.homeDir}`));
|
||||||
@@ -60,8 +72,23 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const config = readConfig(configPath);
|
||||||
|
if (!config) {
|
||||||
|
p.log.error(`No config found at ${configPath}.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
p.log.step("Starting Paperclip server...");
|
p.log.step("Starting Paperclip server...");
|
||||||
await importServerEntry();
|
const startedServer = await importServerEntry();
|
||||||
|
|
||||||
|
if (shouldGenerateBootstrapInviteAfterStart(config)) {
|
||||||
|
p.log.step("Generating bootstrap CEO invite");
|
||||||
|
await bootstrapCeoInvite({
|
||||||
|
config: configPath,
|
||||||
|
dbUrl: startedServer.databaseUrl,
|
||||||
|
baseUrl: startedServer.apiUrl.replace(/\/api$/, ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatError(err: unknown): string {
|
function formatError(err: unknown): string {
|
||||||
@@ -101,19 +128,20 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importServerEntry(): Promise<void> {
|
async function importServerEntry(): Promise<StartedServer> {
|
||||||
// Dev mode: try local workspace path (monorepo with tsx)
|
// Dev mode: try local workspace path (monorepo with tsx)
|
||||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||||
const devEntry = path.resolve(projectRoot, "server/src/index.ts");
|
const devEntry = path.resolve(projectRoot, "server/src/index.ts");
|
||||||
if (fs.existsSync(devEntry)) {
|
if (fs.existsSync(devEntry)) {
|
||||||
maybeEnableUiDevMiddleware(devEntry);
|
maybeEnableUiDevMiddleware(devEntry);
|
||||||
await import(pathToFileURL(devEntry).href);
|
const mod = await import(pathToFileURL(devEntry).href);
|
||||||
return;
|
return await startServerFromModule(mod, devEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Production mode: import the published @paperclipai/server package
|
// Production mode: import the published @paperclipai/server package
|
||||||
try {
|
try {
|
||||||
await import("@paperclipai/server");
|
const mod = await import("@paperclipai/server");
|
||||||
|
return await startServerFromModule(mod, "@paperclipai/server");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const missingSpecifier = getMissingModuleSpecifier(err);
|
const missingSpecifier = getMissingModuleSpecifier(err);
|
||||||
const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server";
|
const missingServerEntrypoint = !missingSpecifier || missingSpecifier === "@paperclipai/server";
|
||||||
@@ -130,3 +158,15 @@ async function importServerEntry(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldGenerateBootstrapInviteAfterStart(config: PaperclipConfig): boolean {
|
||||||
|
return config.server.deploymentMode === "authenticated" && config.database.mode === "embedded-postgres";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServerFromModule(mod: unknown, label: string): Promise<StartedServer> {
|
||||||
|
const startServer = (mod as { startServer?: () => Promise<StartedServer> }).startServer;
|
||||||
|
if (typeof startServer !== "function") {
|
||||||
|
throw new Error(`Paperclip server entrypoint did not export startServer(): ${label}`);
|
||||||
|
}
|
||||||
|
return await startServer();
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export function resolveAgentJwtEnvFile(configPath?: string): string {
|
|||||||
return resolveEnvFilePath(configPath);
|
return resolveEnvFilePath(configPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadPaperclipEnvFile(configPath?: string): void {
|
||||||
|
loadAgentJwtEnvFile(resolveEnvFilePath(configPath));
|
||||||
|
}
|
||||||
|
|
||||||
export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void {
|
export function loadAgentJwtEnvFile(filePath = resolveEnvFilePath()): void {
|
||||||
if (loadedEnvFiles.has(filePath)) return;
|
if (loadedEnvFiles.has(filePath)) return;
|
||||||
|
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ This is the best existing fit when you want:
|
|||||||
- a dedicated host port
|
- a dedicated host port
|
||||||
- an end-to-end `npx paperclipai ... onboard` check
|
- an end-to-end `npx paperclipai ... onboard` check
|
||||||
|
|
||||||
|
In authenticated/private mode, the expected result is a full authenticated onboarding flow, including printing the bootstrap CEO invite once startup completes.
|
||||||
|
|
||||||
If you want to exercise onboarding from a fresh local checkout rather than npm, use:
|
If you want to exercise onboarding from a fresh local checkout rather than npm, use:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
1108
server/src/index.ts
1108
server/src/index.ts
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user