@getpaseo/cli 0.4.0-beta.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ import type { Command } from "commander";
2
2
  import { type SingleResult } from "../../output/index.js";
3
3
  import { HubHttpClient, type HubInstallResult, type HubValidationResult } from "./hub-client/index.js";
4
4
  import { type HubCredentialStore } from "./credentials.js";
5
+ import { type HubDeployBundle } from "./deploy-bundle.js";
5
6
  import { type HubReporter } from "./reporter.js";
6
7
  export interface HubDeployOptions {
7
8
  project?: string;
@@ -10,7 +11,7 @@ export interface HubDeployOptions {
10
11
  dryRun?: boolean;
11
12
  json?: boolean;
12
13
  }
13
- interface HubDeployEnvironment {
14
+ export interface HubDeployEnvironment {
14
15
  cwd: string;
15
16
  env: Readonly<Record<string, string | undefined>>;
16
17
  credentials?: HubCredentialStore;
@@ -33,6 +34,7 @@ interface HubDryRunResult extends HubValidationResult {
33
34
  workflows: number;
34
35
  }
35
36
  export declare function runHubDeploy(options: HubDeployOptions, environment?: HubDeployEnvironment): Promise<SingleResult<HubDeployResult> | SingleResult<HubDryRunResult>>;
37
+ export declare function runHubDeployBundle(options: HubDeployOptions, deployInput: HubDeployBundle, environment: HubDeployEnvironment): Promise<SingleResult<HubDeployResult> | SingleResult<HubDryRunResult>>;
36
38
  export declare function addHubDeployCommand(hub: Command, dependencies: HubDeployCommandDependencies): void;
37
39
  export {};
38
40
  //# sourceMappingURL=deploy.d.ts.map
@@ -32,6 +32,13 @@ export async function runHubDeploy(options, environment = {
32
32
  credentials: new PrivateHubCredentialStore(),
33
33
  hub: new HubHttpClient(),
34
34
  }) {
35
+ const deployInput = await discoverHubBundle({
36
+ cwd: environment.cwd,
37
+ ...(options.project === undefined ? {} : { project: options.project }),
38
+ });
39
+ return runHubDeployBundle(options, deployInput, environment);
40
+ }
41
+ export async function runHubDeployBundle(options, deployInput, environment) {
35
42
  const credentials = environment.credentials ?? new PrivateHubCredentialStore(environment.env);
36
43
  const resolution = {
37
44
  options: { origin: options.hub, apiKey: options.apiKey },
@@ -39,10 +46,6 @@ export async function runHubDeploy(options, environment = {
39
46
  credentials,
40
47
  };
41
48
  const origin = resolveHubOrigin(resolution);
42
- const deployInput = await discoverHubBundle({
43
- cwd: environment.cwd,
44
- ...(options.project === undefined ? {} : { project: options.project }),
45
- });
46
49
  const action = options.dryRun === true ? "Validating" : "Deploying";
47
50
  reportHubProgress(environment.reporter ?? processHubReporter, options, `${action} ${deployInput.projectSlug} ${options.dryRun === true ? "against" : "to"} ${origin}`);
48
51
  const credential = resolveHubCredential({ ...resolution, origin });
@@ -10,13 +10,13 @@ export function runHubDisconnect(options, dependencies) {
10
10
  reportHubProgress(dependencies.reporter, options, `Disconnecting this daemon from ${current.hubOrigin}`);
11
11
  }
12
12
  const response = await client.disconnectHub(options.force ?? false);
13
- return hubStatusResult(response.status, response.warning, current.hubOrigin);
13
+ return hubStatusResult(response.status, response.warning);
14
14
  });
15
15
  }
16
16
  export function addHubDisconnectCommand(parent, dependencies) {
17
17
  addJsonAndDaemonHostOptions(parent
18
18
  .command("disconnect")
19
- .option("--force", "Remove local authority even if the Hub is offline")).action(withOutput(async (...args) => {
19
+ .option("--force", "Remove local authority without notifying the Hub")).action(withOutput(async (...args) => {
20
20
  const options = args.at(-2);
21
21
  return runHubDisconnect(options, dependencies);
22
22
  }));
@@ -1,6 +1,6 @@
1
1
  import type { HubBundleFile } from "../deploy-bundle.js";
2
- import { type CliAuthorization, type CliAuthorizationPoll, type HubInstallResult, type HubProject, type HubValidationResult } from "./internal/contracts.js";
3
- export type { CliAuthorization, CliAuthorizationPoll, HubInstallResult, HubProject, HubValidationResult, } from "./internal/contracts.js";
2
+ import { type CliAuthorization, type CliAuthorizationPoll, type HubInstallResult, type HubConfigurationResources, type HubProject, type HubValidationResult } from "./internal/contracts.js";
3
+ export type { CliAuthorization, CliAuthorizationPoll, HubInstallResult, HubConfigurationResources, HubProject, HubValidationResult, } from "./internal/contracts.js";
4
4
  interface HubConfigurationInput {
5
5
  origin: string;
6
6
  apiKey: string;
@@ -11,6 +11,7 @@ export declare class HubHttpClient {
11
11
  startCliAuthorization(origin: string): Promise<CliAuthorization>;
12
12
  pollCliAuthorization(origin: string, deviceCode: string, timeoutMilliseconds: number): Promise<CliAuthorizationPoll>;
13
13
  listProjects(origin: string, apiKey: string): Promise<HubProject[]>;
14
+ listConfigurationResources(origin: string, apiKey: string): Promise<HubConfigurationResources>;
14
15
  installConfiguration(input: HubConfigurationInput): Promise<HubInstallResult>;
15
16
  validateConfiguration(input: HubConfigurationInput): Promise<HubValidationResult>;
16
17
  issueEnrollmentToken(origin: string, apiKey: string): Promise<string>;
@@ -1,5 +1,5 @@
1
1
  import { HubCommandError } from "../error.js";
2
- import { authorizationPollSchema, authorizationSchema, enrollmentTokenSchema, installResponseSchema, projectsResponseSchema, validationResponseSchema, } from "./internal/contracts.js";
2
+ import { authorizationPollSchema, authorizationSchema, configurationResourcesSchema, enrollmentTokenSchema, installResponseSchema, projectsResponseSchema, validationResponseSchema, } from "./internal/contracts.js";
3
3
  import { requestHub } from "./internal/transport.js";
4
4
  export class HubHttpClient {
5
5
  startCliAuthorization(origin) {
@@ -45,6 +45,17 @@ export class HubHttpClient {
45
45
  });
46
46
  return response.projects;
47
47
  }
48
+ listConfigurationResources(origin, apiKey) {
49
+ return requestHub({
50
+ origin,
51
+ path: "/api/v1/configuration-resources",
52
+ method: "GET",
53
+ apiKey,
54
+ successStatus: 200,
55
+ schema: configurationResourcesSchema,
56
+ failureMessage: "Hub configuration resource listing failed",
57
+ });
58
+ }
48
59
  installConfiguration(input) {
49
60
  return requestHub({
50
61
  origin: input.origin,
@@ -42,6 +42,26 @@ export declare const projectsResponseSchema: z.ZodObject<{
42
42
  name: z.ZodString;
43
43
  }, z.core.$strict>>;
44
44
  }, z.core.$strict>;
45
+ export declare const configurationResourcesSchema: z.ZodObject<{
46
+ daemons: z.ZodArray<z.ZodObject<{
47
+ id: z.ZodString;
48
+ slug: z.ZodString;
49
+ }, z.core.$strict>>;
50
+ github: z.ZodArray<z.ZodObject<{
51
+ slug: z.ZodString;
52
+ accountLogin: z.ZodString;
53
+ accountType: z.ZodString;
54
+ repositories: z.ZodArray<z.ZodString>;
55
+ }, z.core.$strict>>;
56
+ discord: z.ZodArray<z.ZodObject<{
57
+ slug: z.ZodString;
58
+ guildName: z.ZodString;
59
+ }, z.core.$strict>>;
60
+ slack: z.ZodArray<z.ZodObject<{
61
+ slug: z.ZodString;
62
+ teamName: z.ZodString;
63
+ }, z.core.$strict>>;
64
+ }, z.core.$strict>;
45
65
  export declare const installResponseSchema: z.ZodObject<{
46
66
  projectSlug: z.ZodString;
47
67
  version: z.ZodNumber;
@@ -59,6 +79,7 @@ export declare const enrollmentTokenSchema: z.ZodObject<{
59
79
  export type CliAuthorization = z.infer<typeof authorizationSchema>;
60
80
  export type CliAuthorizationPoll = z.infer<typeof authorizationPollSchema>;
61
81
  export type HubProject = z.infer<typeof projectSchema>;
82
+ export type HubConfigurationResources = z.infer<typeof configurationResourcesSchema>;
62
83
  export type HubInstallResult = z.infer<typeof installResponseSchema>;
63
84
  export type HubValidationResult = z.infer<typeof validationResponseSchema>;
64
85
  export {};
@@ -30,6 +30,21 @@ const projectSchema = z
30
30
  .object({ id: z.string().uuid(), slug: z.string().min(1), name: z.string().min(1) })
31
31
  .strict();
32
32
  export const projectsResponseSchema = z.object({ projects: z.array(projectSchema) }).strict();
33
+ export const configurationResourcesSchema = z
34
+ .object({
35
+ daemons: z.array(z.object({ id: z.string().uuid(), slug: z.string().min(1) }).strict()),
36
+ github: z.array(z
37
+ .object({
38
+ slug: z.string().min(1),
39
+ accountLogin: z.string().min(1),
40
+ accountType: z.string().min(1),
41
+ repositories: z.array(z.string().min(1)),
42
+ })
43
+ .strict()),
44
+ discord: z.array(z.object({ slug: z.string().min(1), guildName: z.string().min(1) }).strict()),
45
+ slack: z.array(z.object({ slug: z.string().min(1), teamName: z.string().min(1) }).strict()),
46
+ })
47
+ .strict();
33
48
  export const installResponseSchema = z
34
49
  .object({
35
50
  projectSlug: z.string().min(1),
@@ -35,9 +35,15 @@ export async function hubRequestFailure(response, failureMessage, apiKey) {
35
35
  return new HubCommandError("HUB_INVALID_RESPONSE", `Hub returned nonconforming problem details for HTTP ${response.status}.`);
36
36
  }
37
37
  const title = parsed.data.title ?? `${failureMessage} with HTTP ${response.status}`;
38
- const message = parsed.data.detail === undefined ? title : `${title}: ${parsed.data.detail}`;
39
38
  const details = formatFieldIssues(parsed.data.errors, parsed.data.issues);
40
- const code = response.status === 422 ? "HUB_VALIDATION_FAILED" : "HUB_REQUEST_FAILED";
39
+ const message = details !== undefined || parsed.data.detail === undefined
40
+ ? title
41
+ : `${title}: ${parsed.data.detail}`;
42
+ let code = "HUB_REQUEST_FAILED";
43
+ if (response.status === 422)
44
+ code = "HUB_VALIDATION_FAILED";
45
+ if (response.status === 404)
46
+ code = "HUB_NOT_FOUND";
41
47
  return new HubCommandError(code, redactSecret(message, apiKey), details === undefined ? undefined : redactSecret(details, apiKey));
42
48
  }
43
49
  function formatFieldIssues(errors, issues) {
@@ -57,6 +63,13 @@ function formatFieldIssues(errors, issues) {
57
63
  function formatIssuePath(path) {
58
64
  if (path === undefined || typeof path === "string")
59
65
  return path;
66
+ const [file, ...fieldPath] = path;
67
+ if (typeof file === "string" && file.startsWith(".paseo/") && fieldPath.length > 0) {
68
+ return `${file}: ${formatPathSegments(fieldPath)}`;
69
+ }
70
+ return formatPathSegments(path) || undefined;
71
+ }
72
+ function formatPathSegments(path) {
60
73
  let formatted = "";
61
74
  for (const segment of path) {
62
75
  if (typeof segment === "number")
@@ -64,7 +77,7 @@ function formatIssuePath(path) {
64
77
  else
65
78
  formatted += formatted.length === 0 ? segment : `.${segment}`;
66
79
  }
67
- return formatted || undefined;
80
+ return formatted;
68
81
  }
69
82
  function redactSecret(value, secret) {
70
83
  return secret === undefined ? value : value.split(secret).join("[redacted]");
@@ -14,6 +14,7 @@ import { addHubProjectsCommand } from "./projects.js";
14
14
  import { processHubReporter } from "./reporter.js";
15
15
  import { hubStatusResult } from "./status-output.js";
16
16
  import { addHubResolutionHelp } from "./help.js";
17
+ import { addHubInitCommand } from "./init.js";
17
18
  function productionEnvironment() {
18
19
  const env = process.env;
19
20
  const hub = new HubHttpClient();
@@ -37,6 +38,7 @@ export function createHubCommand(overrides = {}) {
37
38
  flow: environment.login,
38
39
  reporter: environment.reporter,
39
40
  });
41
+ addHubInitCommand(hub, environment);
40
42
  addHubConnectCommand(hub, {
41
43
  env: environment.env,
42
44
  credentials: environment.credentials,
@@ -0,0 +1,50 @@
1
+ import type { HubDeployBundle } from "./deploy-bundle.js";
2
+ import type { HubProject } from "./hub-client/index.js";
3
+ import type { HubStatus } from "./daemon-client.js";
4
+ export type HubInitProvider = "github" | "slack" | "discord";
5
+ export type HubInitProjectResolution = {
6
+ kind: "none";
7
+ } | {
8
+ kind: "selected";
9
+ project: HubProject;
10
+ } | {
11
+ kind: "choose";
12
+ projects: readonly HubProject[];
13
+ };
14
+ export type HubInitConnectionResolution = {
15
+ kind: "connected";
16
+ daemonId: string;
17
+ } | {
18
+ kind: "pending";
19
+ state: "connecting" | "reconnecting";
20
+ } | {
21
+ kind: "connect";
22
+ } | {
23
+ kind: "conflict";
24
+ origin: string;
25
+ };
26
+ export interface HubInitOpeningPlan {
27
+ replaceExisting: boolean;
28
+ steps: readonly ("login" | "connect" | "project" | "scaffold")[];
29
+ }
30
+ export interface HubInitScaffoldInput {
31
+ cwd: string;
32
+ daemonSlug: string;
33
+ provider: HubInitProvider;
34
+ providerFilters: Readonly<Record<string, string>>;
35
+ }
36
+ export interface HubInitScaffold {
37
+ hub: string;
38
+ workflowPath: string;
39
+ workflow: string;
40
+ testAction: string;
41
+ }
42
+ export declare function planHubInitOpening(input: {
43
+ loggedIn: boolean;
44
+ paseoDirectoryExists: boolean;
45
+ }): HubInitOpeningPlan;
46
+ export declare function createHubInitBundle(projectSlug: string, scaffold: HubInitScaffold): HubDeployBundle;
47
+ export declare function resolveHubInitProjects(projects: readonly HubProject[]): HubInitProjectResolution;
48
+ export declare function resolveHubInitConnection(status: HubStatus, origin: string): HubInitConnectionResolution;
49
+ export declare function createHubInitScaffold(input: HubInitScaffoldInput): HubInitScaffold;
50
+ //# sourceMappingURL=init-plan.d.ts.map
@@ -0,0 +1,136 @@
1
+ import path from "node:path";
2
+ import YAML from "yaml";
3
+ export function planHubInitOpening(input) {
4
+ return {
5
+ replaceExisting: input.paseoDirectoryExists,
6
+ steps: [...(input.loggedIn ? [] : ["login"]), "connect", "project", "scaffold"],
7
+ };
8
+ }
9
+ export function createHubInitBundle(projectSlug, scaffold) {
10
+ return {
11
+ projectSlug,
12
+ workflowCount: 1,
13
+ files: [
14
+ { path: ".paseo/hub.yml", content: scaffold.hub },
15
+ { path: scaffold.workflowPath, content: scaffold.workflow },
16
+ ],
17
+ };
18
+ }
19
+ export function resolveHubInitProjects(projects) {
20
+ if (projects.length === 0)
21
+ return { kind: "none" };
22
+ if (projects.length === 1)
23
+ return { kind: "selected", project: projects[0] };
24
+ return { kind: "choose", projects };
25
+ }
26
+ export function resolveHubInitConnection(status, origin) {
27
+ if (status.state === "connected" && status.hubOrigin === origin && status.daemonId !== null) {
28
+ return { kind: "connected", daemonId: status.daemonId };
29
+ }
30
+ if ((status.state === "connecting" || status.state === "reconnecting") &&
31
+ status.hubOrigin === origin) {
32
+ return { kind: "pending", state: status.state };
33
+ }
34
+ if (status.hubOrigin !== null && status.state !== "not_connected" && status.state !== "revoked") {
35
+ return { kind: "conflict", origin: status.hubOrigin };
36
+ }
37
+ return { kind: "connect" };
38
+ }
39
+ export function createHubInitScaffold(input) {
40
+ const environmentName = input.daemonSlug;
41
+ const hub = YAML.stringify({
42
+ environments: {
43
+ [environmentName]: {
44
+ kind: "daemon",
45
+ daemon: input.daemonSlug,
46
+ cwd: path.resolve(input.cwd),
47
+ },
48
+ },
49
+ agents: {
50
+ codex: {
51
+ provider: "codex",
52
+ mode: "full-access",
53
+ },
54
+ },
55
+ }, { lineWidth: 0 });
56
+ const provider = providerScaffold(input.provider, input.providerFilters, environmentName);
57
+ return {
58
+ hub,
59
+ workflowPath: `.paseo/workflows/${input.provider}-help.yml`,
60
+ workflow: YAML.stringify(provider.workflow, { lineWidth: 0 }),
61
+ testAction: provider.testAction,
62
+ };
63
+ }
64
+ function providerScaffold(provider, filters, environment) {
65
+ if (provider === "github") {
66
+ const repo = requireFilter(filters, "repo");
67
+ const user = requireFilter(filters, "user");
68
+ return {
69
+ workflow: workflow({
70
+ name: "github-help",
71
+ on: "github.issue_comment",
72
+ filters: { repo, contains: "@paseo", from_users: [user] },
73
+ environment,
74
+ }),
75
+ testAction: `Comment \`@paseo have a look\` on ${repo}.`,
76
+ };
77
+ }
78
+ const user = requireFilter(filters, "user");
79
+ if (provider === "slack") {
80
+ const workspace = requireFilter(filters, "workspace");
81
+ return {
82
+ workflow: workflow({
83
+ name: "slack-help",
84
+ on: "slack.mention",
85
+ filters: { workspace, from_users: [user] },
86
+ environment,
87
+ reply: "slack.reply",
88
+ }),
89
+ testAction: "Mention `@Paseo have a look` in Slack.",
90
+ };
91
+ }
92
+ const guild = requireFilter(filters, "guild");
93
+ return {
94
+ workflow: workflow({
95
+ name: "discord-help",
96
+ on: "discord.mention",
97
+ filters: { guild, from_users: [user] },
98
+ environment,
99
+ reply: "discord.reply",
100
+ }),
101
+ testAction: "Mention `@Paseo have a look` in Discord.",
102
+ };
103
+ }
104
+ function workflow(input) {
105
+ const replyInstruction = input.reply === undefined ? "" : "Answer with hub.reply, then ";
106
+ return {
107
+ name: input.name,
108
+ on: input.on,
109
+ max_runtime: "2h",
110
+ filters: input.filters,
111
+ steps: [
112
+ {
113
+ id: "work",
114
+ environment: input.environment,
115
+ max_runtime: "90m",
116
+ idle_timeout: "10m",
117
+ agent: "codex",
118
+ prompt: [
119
+ {
120
+ text: `${replyInstruction}complete this request and call hub.finish_execution when done.\n\n<user-prompt>\n\${{ paseo.prompt }}\n</user-prompt>\n`,
121
+ },
122
+ ],
123
+ ...(input.reply === undefined
124
+ ? {}
125
+ : { allow_outputs: [{ type: input.reply, max: 1, required: true }] }),
126
+ },
127
+ ],
128
+ };
129
+ }
130
+ function requireFilter(filters, name) {
131
+ const value = filters[name]?.trim();
132
+ if (!value)
133
+ throw new Error(`${name} is required for this provider`);
134
+ return value;
135
+ }
136
+ //# sourceMappingURL=init-plan.js.map
@@ -0,0 +1,20 @@
1
+ import type { Command } from "commander";
2
+ import type { HubCredentialStore } from "./credentials.js";
3
+ import type { HubDaemonConnection } from "./daemon-client.js";
4
+ import type { HubHttpClient } from "./hub-client/index.js";
5
+ import type { CliLoginFlow } from "./login-flow.js";
6
+ import type { HubReporter } from "./reporter.js";
7
+ interface HubInitEnvironment {
8
+ env: Readonly<Record<string, string | undefined>>;
9
+ credentials: HubCredentialStore;
10
+ hub: HubHttpClient;
11
+ login: Pick<CliLoginFlow, "authorize">;
12
+ daemon: HubDaemonConnection;
13
+ reporter: HubReporter;
14
+ cwd(): string;
15
+ }
16
+ export declare function addHubInitCommand(parent: Command, environment: HubInitEnvironment): void;
17
+ export declare function runHubInit(environment: HubInitEnvironment): Promise<void>;
18
+ export declare function githubRepositoryFromRemote(remote: string): string | undefined;
19
+ export {};
20
+ //# sourceMappingURL=init.d.ts.map
@@ -0,0 +1,415 @@
1
+ import { cancel, confirm, intro, isCancel, log, note, outro, select, spinner, text, } from "@clack/prompts";
2
+ import { execFile } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { DEFAULT_HUB_ORIGIN, resolveHubCredential } from "./authority.js";
8
+ import { withHubDaemon } from "./daemon-client.js";
9
+ import { HubCommandError } from "./error.js";
10
+ import { createHubInitBundle, createHubInitScaffold, planHubInitOpening, resolveHubInitConnection, resolveHubInitProjects, } from "./init-plan.js";
11
+ import { runHubLogin } from "./login.js";
12
+ import { runHubConnect } from "./connect.js";
13
+ import { runHubDeployBundle } from "./deploy.js";
14
+ import { runHubProjects } from "./projects.js";
15
+ import { normalizeHubOrigin } from "./origin.js";
16
+ const execFileAsync = promisify(execFile);
17
+ const DAEMON_READY_TIMEOUT_MS = 60000;
18
+ const DAEMON_READY_POLL_MS = 250;
19
+ class HubInitCancelledError extends Error {
20
+ }
21
+ function initErrorMessage(error) {
22
+ if (error instanceof HubCommandError && error.details) {
23
+ return `${error.message}\n${error.details}`;
24
+ }
25
+ return error instanceof Error ? error.message : String(error);
26
+ }
27
+ export function addHubInitCommand(parent, environment) {
28
+ parent
29
+ .command("init")
30
+ .description("Create and optionally deploy a safe starter Hub bundle")
31
+ .action(async () => {
32
+ try {
33
+ await runHubInit(environment);
34
+ }
35
+ catch (error) {
36
+ if (error instanceof HubInitCancelledError) {
37
+ cancel(error.message);
38
+ return;
39
+ }
40
+ cancel(initErrorMessage(error));
41
+ process.exitCode = 1;
42
+ }
43
+ });
44
+ }
45
+ export async function runHubInit(environment) {
46
+ requireInteractiveTerminal();
47
+ intro("Set up Paseo Hub");
48
+ const cwd = environment.cwd();
49
+ const activeLogin = environment.credentials.active();
50
+ const opening = planHubInitOpening({
51
+ loggedIn: activeLogin !== null,
52
+ paseoDirectoryExists: await pathExists(path.join(cwd, ".paseo")),
53
+ });
54
+ if (opening.replaceExisting &&
55
+ !(await requiredConfirm("Replace the existing .paseo/ Hub bundle?", false))) {
56
+ throw new HubInitCancelledError("Existing .paseo/ bundle left unchanged.");
57
+ }
58
+ const origin = await ensureLogin(activeLogin?.origin, environment);
59
+ const daemonId = await ensureDaemonConnection(origin, environment);
60
+ const project = await chooseProject(origin, environment);
61
+ const resources = await loadConfigurationResources(origin, environment);
62
+ const daemon = resources.daemons.find(({ id }) => id === daemonId);
63
+ if (daemon === undefined) {
64
+ throw new HubCommandError("HUB_DAEMON_RESOURCE_MISSING", "The connected daemon is not available in this Hub organization. Reconnect it and try again.");
65
+ }
66
+ log.success(`Connected as ${daemon.slug}`);
67
+ const provider = await chooseProvider();
68
+ const providerFilters = await collectProviderFilters(provider, resources, cwd);
69
+ const scaffold = createHubInitScaffold({
70
+ cwd,
71
+ daemonSlug: daemon.slug,
72
+ provider,
73
+ providerFilters,
74
+ });
75
+ const bundle = createHubInitBundle(project.slug, scaffold);
76
+ await withSpinner("Validating bundle", async () => {
77
+ await runHubDeployBundle({ project: project.slug, hub: origin, dryRun: true }, bundle, {
78
+ cwd,
79
+ env: environment.env,
80
+ credentials: environment.credentials,
81
+ hub: environment.hub,
82
+ reporter: { progress() { } },
83
+ });
84
+ });
85
+ log.success("Dry run passed");
86
+ await writeScaffold(cwd, scaffold, opening.replaceExisting);
87
+ log.success(`Created .paseo/hub.yml and ${scaffold.workflowPath}`);
88
+ const deploy = await requiredConfirm("Deploy now?", true);
89
+ if (deploy) {
90
+ await withSpinner("Deploying bundle", async () => {
91
+ await runHubDeployBundle({ project: project.slug, hub: origin }, bundle, {
92
+ cwd,
93
+ env: environment.env,
94
+ credentials: environment.credentials,
95
+ hub: environment.hub,
96
+ reporter: { progress() { } },
97
+ });
98
+ });
99
+ log.success("Deployed");
100
+ }
101
+ else {
102
+ log.message(`Skipped deployment. Run: paseo hub deploy -p ${project.slug}`);
103
+ }
104
+ const activityUrl = new URL(`/projects/${project.slug}/activity`, origin).toString();
105
+ note(`${scaffold.testAction}\nWatch it at ${activityUrl}`, "Test your workflow");
106
+ outro(deploy ? "Hub is ready" : "Hub bundle is ready");
107
+ }
108
+ async function ensureLogin(activeOrigin, environment) {
109
+ const endpoint = await requiredSelect({
110
+ message: "Hub endpoint",
111
+ initialValue: activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN ? "hosted" : "custom",
112
+ options: [
113
+ { value: "hosted", label: "hub.paseo.sh" },
114
+ { value: "custom", label: "Custom endpoint…" },
115
+ ],
116
+ });
117
+ const origin = endpoint === "hosted"
118
+ ? DEFAULT_HUB_ORIGIN
119
+ : await requiredText({
120
+ message: "Custom Hub URL",
121
+ initialValue: activeOrigin === undefined || activeOrigin === DEFAULT_HUB_ORIGIN
122
+ ? environment.env.PASEO_HUB_URL
123
+ : activeOrigin,
124
+ validate(value) {
125
+ try {
126
+ normalizeHubOrigin(value ?? "");
127
+ }
128
+ catch {
129
+ return "Enter a valid Hub URL";
130
+ }
131
+ return undefined;
132
+ },
133
+ });
134
+ const normalizedOrigin = normalizeHubOrigin(origin);
135
+ if (environment.credentials.get(normalizedOrigin) !== null) {
136
+ log.success(`Logged in to ${normalizedOrigin}`);
137
+ return normalizedOrigin;
138
+ }
139
+ await runHubLogin(normalizedOrigin, {}, {
140
+ env: environment.env,
141
+ credentials: environment.credentials,
142
+ flow: environment.login,
143
+ reporter: environment.reporter,
144
+ });
145
+ log.success(`Logged in to ${normalizedOrigin}`);
146
+ return normalizedOrigin;
147
+ }
148
+ async function ensureDaemonConnection(origin, environment) {
149
+ const status = await withHubDaemon(environment.daemon, undefined, async (daemon) => daemon.getHubStatus().then((response) => response.status));
150
+ const connection = resolveHubInitConnection(status, origin);
151
+ if (connection.kind === "connected") {
152
+ return connection.daemonId;
153
+ }
154
+ if (connection.kind === "pending") {
155
+ return waitForDaemonReady(origin, environment.daemon);
156
+ }
157
+ if (connection.kind === "conflict") {
158
+ throw new HubCommandError("HUB_DAEMON_ALREADY_CONNECTED", `This daemon is connected to ${connection.origin}. Disconnect it before running Hub init for ${origin}.`);
159
+ }
160
+ if (!(await requiredConfirm(`Connect this daemon to ${origin}?`, true))) {
161
+ throw new HubInitCancelledError("A connected daemon is required to create the bundle.");
162
+ }
163
+ await runHubConnect(origin, {}, {
164
+ env: environment.env,
165
+ credentials: environment.credentials,
166
+ hub: environment.hub,
167
+ daemon: environment.daemon,
168
+ reporter: environment.reporter,
169
+ });
170
+ return waitForDaemonReady(origin, environment.daemon);
171
+ }
172
+ async function waitForDaemonReady(origin, connection) {
173
+ return withSpinner("Waiting for the daemon to connect", async (reporter) => withHubDaemon(connection, undefined, async (daemon) => {
174
+ const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
175
+ while (true) {
176
+ const status = (await daemon.getHubStatus()).status;
177
+ const resolution = resolveHubInitConnection(status, origin);
178
+ if (resolution.kind === "connected")
179
+ return resolution.daemonId;
180
+ if (resolution.kind === "conflict") {
181
+ throw new HubCommandError("HUB_DAEMON_ALREADY_CONNECTED", `This daemon connected to ${resolution.origin} while Hub init was waiting for ${origin}.`);
182
+ }
183
+ if (resolution.kind === "connect") {
184
+ throw new HubCommandError("HUB_DAEMON_CONNECTION_LOST", "The daemon lost its Hub relationship while Hub init was waiting for it.");
185
+ }
186
+ if (Date.now() >= deadline) {
187
+ throw new HubCommandError("HUB_DAEMON_CONNECTION_TIMEOUT", "The daemon did not connect within 60 seconds. Check `paseo hub status`, then run Hub init again.");
188
+ }
189
+ reporter.progress(`Daemon is ${resolution.state}`);
190
+ await delay(DAEMON_READY_POLL_MS);
191
+ }
192
+ }));
193
+ }
194
+ async function chooseProject(origin, environment) {
195
+ const result = await runHubProjects({ hub: origin }, {
196
+ env: environment.env,
197
+ credentials: environment.credentials,
198
+ hub: environment.hub,
199
+ reporter: { progress() { } },
200
+ });
201
+ const resolution = resolveHubInitProjects(result.data.projects);
202
+ if (resolution.kind === "none") {
203
+ throw new HubInitCancelledError(`No Hub projects exist yet. Create one at ${new URL("/projects/new", origin).toString()}, then run paseo hub init again.`);
204
+ }
205
+ if (resolution.kind === "selected") {
206
+ return resolution.project;
207
+ }
208
+ const slug = await requiredSelect({
209
+ message: "Project",
210
+ options: resolution.projects.map((project) => ({
211
+ value: project.slug,
212
+ label: project.name,
213
+ hint: project.slug,
214
+ })),
215
+ });
216
+ const project = resolution.projects.find((candidate) => candidate.slug === slug);
217
+ if (project === undefined) {
218
+ throw new HubCommandError("HUB_PROJECT_SELECTION_INVALID", "The selected Hub project is unavailable.");
219
+ }
220
+ return project;
221
+ }
222
+ async function chooseProvider() {
223
+ return requiredSelect({
224
+ message: "Trigger provider",
225
+ options: [
226
+ { value: "github", label: "GitHub", hint: "issue or pull request comment" },
227
+ { value: "slack", label: "Slack", hint: "channel mention" },
228
+ { value: "discord", label: "Discord", hint: "channel mention" },
229
+ ],
230
+ });
231
+ }
232
+ async function collectProviderFilters(provider, resources, cwd) {
233
+ if (provider === "github") {
234
+ const [login, originRemote] = await Promise.all([
235
+ readGhValue(["api", "user", "--jq", ".login"]),
236
+ readCommandValue("git", ["remote", "get-url", "origin"], cwd),
237
+ ]);
238
+ const repo = originRemote === undefined ? undefined : githubRepositoryFromRemote(originRemote);
239
+ if (repo === undefined) {
240
+ throw new HubCommandError("HUB_GITHUB_REPOSITORY_UNDETECTED", "Could not detect a GitHub repository from the origin remote.");
241
+ }
242
+ if (!resources.github.some(({ repositories }) => repositories.includes(repo))) {
243
+ const connected = resources.github.flatMap(({ repositories }) => repositories);
244
+ throw new HubCommandError("HUB_GITHUB_REPOSITORY_NOT_CONNECTED", `${repo} is not connected to this Hub organization. Connected repositories: ${connected.join(", ") || "none"}.`);
245
+ }
246
+ return {
247
+ user: await requiredText({
248
+ message: "Your GitHub username (only this user can trigger the bot)",
249
+ initialValue: login,
250
+ }),
251
+ repo,
252
+ };
253
+ }
254
+ if (provider === "slack") {
255
+ return {
256
+ workspace: await chooseConnection("Slack workspace", resources.slack.map(({ slug, teamName }) => ({ slug, label: teamName }))),
257
+ user: await requiredText({
258
+ message: "Your Slack username (only this user can trigger the bot)",
259
+ }),
260
+ };
261
+ }
262
+ return {
263
+ guild: await chooseConnection("Discord server", resources.discord.map(({ slug, guildName }) => ({ slug, label: guildName }))),
264
+ user: await requiredText({
265
+ message: "Your Discord username (only this user can trigger the bot)",
266
+ }),
267
+ };
268
+ }
269
+ async function loadConfigurationResources(origin, environment) {
270
+ try {
271
+ return await withSpinner("Loading Hub connections", () => environment.hub.listConfigurationResources(origin, resolveHubCredential({
272
+ options: { origin },
273
+ env: environment.env,
274
+ credentials: environment.credentials,
275
+ origin,
276
+ })));
277
+ }
278
+ catch (error) {
279
+ if (error instanceof HubCommandError && error.code === "HUB_NOT_FOUND") {
280
+ throw new HubCommandError("HUB_UPDATE_REQUIRED", "This Hub does not support guided setup. Update the Hub and try again.");
281
+ }
282
+ throw error;
283
+ }
284
+ }
285
+ async function chooseConnection(message, connections) {
286
+ if (connections.length === 0) {
287
+ throw new HubCommandError("HUB_PROVIDER_CONNECTION_REQUIRED", `No ${message.toLowerCase()} is connected in this Hub organization. Connect one and try again.`);
288
+ }
289
+ if (connections.length === 1)
290
+ return connections[0].slug;
291
+ return requiredSelect({
292
+ message,
293
+ options: connections.map(({ slug, label }) => ({ value: slug, label })),
294
+ });
295
+ }
296
+ async function writeScaffold(cwd, scaffold, replaceExisting) {
297
+ const root = path.join(cwd, ".paseo");
298
+ const staging = path.join(cwd, `.paseo-init-${randomUUID()}`);
299
+ const backup = path.join(cwd, `.paseo-backup-${randomUUID()}`);
300
+ let movedExisting = false;
301
+ try {
302
+ await mkdir(path.join(staging, "workflows"), { recursive: true });
303
+ await writeFile(path.join(staging, "hub.yml"), scaffold.hub, { flag: "wx" });
304
+ await writeFile(path.join(staging, "workflows", path.basename(scaffold.workflowPath)), scaffold.workflow, {
305
+ flag: "wx",
306
+ });
307
+ if (replaceExisting) {
308
+ await rename(root, backup);
309
+ movedExisting = true;
310
+ }
311
+ await rename(staging, root);
312
+ if (movedExisting)
313
+ await rm(backup, { recursive: true, force: true });
314
+ }
315
+ catch (error) {
316
+ await rm(staging, { recursive: true, force: true });
317
+ if (movedExisting) {
318
+ await rename(backup, root).catch(() => undefined);
319
+ }
320
+ throw error;
321
+ }
322
+ }
323
+ async function pathExists(target) {
324
+ try {
325
+ await lstat(target);
326
+ return true;
327
+ }
328
+ catch (error) {
329
+ if (errorCode(error) === "ENOENT")
330
+ return false;
331
+ throw error;
332
+ }
333
+ }
334
+ function errorCode(error) {
335
+ return error instanceof Error && "code" in error && typeof error.code === "string"
336
+ ? error.code
337
+ : undefined;
338
+ }
339
+ function delay(milliseconds) {
340
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
341
+ }
342
+ async function readGhValue(args) {
343
+ return readCommandValue("gh", args);
344
+ }
345
+ async function readCommandValue(command, args, cwd) {
346
+ try {
347
+ const { stdout } = await execFileAsync(command, [...args], { encoding: "utf8", cwd });
348
+ return stdout.trim() || undefined;
349
+ }
350
+ catch {
351
+ return undefined;
352
+ }
353
+ }
354
+ export function githubRepositoryFromRemote(remote) {
355
+ const trimmed = remote.trim().replace(/\.git$/u, "");
356
+ const ssh = /^(?:ssh:\/\/)?git@github\.com[:/]([^/]+\/[^/]+)$/u.exec(trimmed);
357
+ if (ssh?.[1] !== undefined)
358
+ return ssh[1];
359
+ try {
360
+ const url = new URL(trimmed);
361
+ if (url.hostname !== "github.com")
362
+ return undefined;
363
+ const repository = url.pathname.replace(/^\//u, "");
364
+ return /^[^/]+\/[^/]+$/u.test(repository) ? repository : undefined;
365
+ }
366
+ catch {
367
+ return undefined;
368
+ }
369
+ }
370
+ async function withSpinner(message, action) {
371
+ const progress = spinner();
372
+ progress.start(message);
373
+ try {
374
+ const result = await action({ progress: (nextMessage) => progress.message(nextMessage) });
375
+ progress.stop(message);
376
+ return result;
377
+ }
378
+ catch (error) {
379
+ progress.error(message);
380
+ throw error;
381
+ }
382
+ }
383
+ async function requiredText(options) {
384
+ const answer = await text({
385
+ ...options,
386
+ validate(value) {
387
+ const input = value ?? "";
388
+ const customError = options.validate?.(input);
389
+ if (customError !== undefined)
390
+ return customError;
391
+ return input.trim().length === 0 ? "A value is required" : undefined;
392
+ },
393
+ });
394
+ if (isCancel(answer))
395
+ throw new HubInitCancelledError("Hub init cancelled.");
396
+ return answer.trim();
397
+ }
398
+ async function requiredConfirm(message, initialValue) {
399
+ const answer = await confirm({ message, initialValue });
400
+ if (isCancel(answer))
401
+ throw new HubInitCancelledError("Hub init cancelled.");
402
+ return answer;
403
+ }
404
+ async function requiredSelect(options) {
405
+ const answer = await select(options);
406
+ if (isCancel(answer))
407
+ throw new HubInitCancelledError("Hub init cancelled.");
408
+ return answer;
409
+ }
410
+ function requireInteractiveTerminal() {
411
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
412
+ throw new HubCommandError("HUB_INIT_INTERACTIVE_REQUIRED", "paseo hub init requires a TTY.");
413
+ }
414
+ }
415
+ //# sourceMappingURL=init.js.map
@@ -7,6 +7,7 @@ interface HubLogoutResult {
7
7
  origin: string | null;
8
8
  status: "logged_out" | "not_logged_in";
9
9
  daemonDisconnected: boolean;
10
+ warning?: string;
10
11
  }
11
12
  interface HubLogoutOptions {
12
13
  disconnectDaemon?: boolean;
@@ -9,6 +9,7 @@ const schema = {
9
9
  { header: "HUB", field: "origin" },
10
10
  { header: "STATUS", field: "status" },
11
11
  { header: "DAEMON DISCONNECTED", field: "daemonDisconnected" },
12
+ { header: "WARNING", field: "warning" },
12
13
  ],
13
14
  };
14
15
  export async function runHubLogout(options, dependencies) {
@@ -33,18 +34,23 @@ export async function runHubLogout(options, dependencies) {
33
34
  return logoutResult({ origin: active.origin, status: "logged_out", daemonDisconnected: false });
34
35
  }
35
36
  reportHubProgress(dependencies.reporter, options, `Disconnecting this daemon from ${active.origin}`);
36
- await withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
37
- await daemon.disconnectHub(options.force ?? false);
37
+ const disconnect = await withHubDaemon(dependencies.daemon, options.host, async (daemon) => {
38
+ return daemon.disconnectHub(options.force ?? false);
38
39
  });
39
40
  dependencies.credentials.logoutActive();
40
- return logoutResult({ origin: active.origin, status: "logged_out", daemonDisconnected: true });
41
+ return logoutResult({
42
+ origin: active.origin,
43
+ status: "logged_out",
44
+ daemonDisconnected: disconnect.status.state === "not_connected",
45
+ ...(disconnect.warning ? { warning: disconnect.warning } : {}),
46
+ });
41
47
  }
42
48
  export function addHubLogoutCommand(parent, dependencies) {
43
49
  addJsonAndDaemonHostOptions(parent
44
50
  .command("logout")
45
51
  .description("Remove the active stored Hub CLI login")
46
52
  .option("--disconnect-daemon", "Also disconnect a daemon related to the same Hub")
47
- .option("--force", "Remove daemon authority even if Hub is offline")).action(withOutput(async (...args) => {
53
+ .option("--force", "Remove daemon authority without notifying the Hub")).action(withOutput(async (...args) => {
48
54
  const options = args.at(-2);
49
55
  return runHubLogout(options, dependencies);
50
56
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpaseo/cli",
3
- "version": "0.4.0-beta.2",
3
+ "version": "0.4.0",
4
4
  "description": "Paseo CLI - control your AI coding agents from the command line",
5
5
  "bin": {
6
6
  "paseo": "bin/paseo"
@@ -28,9 +28,9 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@clack/prompts": "^1.0.0",
31
- "@getpaseo/client": "0.4.0-beta.2",
32
- "@getpaseo/protocol": "0.4.0-beta.2",
33
- "@getpaseo/server": "0.4.0-beta.2",
31
+ "@getpaseo/client": "0.4.0",
32
+ "@getpaseo/protocol": "0.4.0",
33
+ "@getpaseo/server": "0.4.0",
34
34
  "chalk": "^5.3.0",
35
35
  "commander": "^12.0.0",
36
36
  "mime-types": "^2.1.35",