@otto-code/cli 0.8.10 → 0.8.13
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.
- package/dist/cli.js +1 -5
- package/dist/commands/daemon/index.js +1 -0
- package/dist/commands/daemon/local-daemon.js +3 -0
- package/dist/commands/daemon/pair.d.ts +27 -1
- package/dist/commands/daemon/pair.js +133 -46
- package/dist/commands/daemon/start.js +1 -0
- package/dist/commands/daemon/status.d.ts +11 -0
- package/dist/commands/daemon/status.js +47 -22
- package/dist/commands/hub/authority.d.ts +17 -0
- package/dist/commands/hub/authority.js +18 -0
- package/dist/commands/hub/connect.d.ts +21 -0
- package/dist/commands/hub/connect.js +34 -0
- package/dist/commands/hub/credentials.d.ts +21 -0
- package/dist/commands/hub/credentials.js +143 -0
- package/dist/commands/hub/daemon-client.d.ts +27 -0
- package/dist/commands/hub/daemon-client.js +14 -0
- package/dist/commands/hub/deploy-bundle.d.ts +16 -0
- package/dist/commands/hub/deploy-bundle.js +248 -0
- package/dist/commands/hub/deploy.d.ts +40 -0
- package/dist/commands/hub/deploy.js +90 -0
- package/dist/commands/hub/disconnect.d.ts +16 -0
- package/dist/commands/hub/disconnect.js +24 -0
- package/dist/commands/hub/error.d.ts +6 -0
- package/dist/commands/hub/error.js +9 -0
- package/dist/commands/hub/help.d.ts +3 -0
- package/dist/commands/hub/help.js +5 -0
- package/dist/commands/hub/hub-client/index.d.ts +19 -0
- package/dist/commands/hub/hub-client/index.js +99 -0
- package/dist/commands/hub/hub-client/internal/contracts.d.ts +86 -0
- package/dist/commands/hub/hub-client/internal/contracts.js +62 -0
- package/dist/commands/hub/hub-client/internal/problem.d.ts +3 -0
- package/dist/commands/hub/hub-client/internal/problem.js +85 -0
- package/dist/commands/hub/hub-client/internal/transport.d.ts +15 -0
- package/dist/commands/hub/hub-client/internal/transport.js +30 -0
- package/dist/commands/hub/index.d.ts +15 -25
- package/dist/commands/hub/index.js +67 -73
- package/dist/commands/hub/init-plan.d.ts +50 -0
- package/dist/commands/hub/init-plan.js +136 -0
- package/dist/commands/hub/init.d.ts +20 -0
- package/dist/commands/hub/init.js +415 -0
- package/dist/commands/hub/{device-authorization.d.ts → login-flow.d.ts} +12 -15
- package/dist/commands/hub/login-flow.js +78 -0
- package/dist/commands/hub/login.d.ts +21 -0
- package/dist/commands/hub/login.js +34 -0
- package/dist/commands/hub/logout.d.ts +32 -0
- package/dist/commands/hub/logout.js +71 -0
- package/dist/commands/hub/origin.d.ts +2 -0
- package/dist/commands/hub/origin.js +27 -0
- package/dist/commands/hub/projects.d.ts +24 -0
- package/dist/commands/hub/projects.js +49 -0
- package/dist/commands/hub/reporter.d.ts +10 -0
- package/dist/commands/hub/reporter.js +11 -0
- package/dist/commands/hub/status-output.d.ts +13 -0
- package/dist/commands/hub/status-output.js +30 -0
- package/dist/commands/onboard.js +20 -40
- package/dist/commands/provider/diagnostic.d.ts +12 -0
- package/dist/commands/provider/diagnostic.js +31 -0
- package/dist/commands/provider/index.js +5 -0
- package/dist/commands/schedule/types.d.ts +1 -4
- package/dist/commands/workspace/index.js +10 -0
- package/dist/commands/workspace/rename.d.ts +22 -0
- package/dist/commands/workspace/rename.js +61 -0
- package/dist/output/types.d.ts +1 -1
- package/dist/run.js +8 -1
- package/dist/utils/client.js +4 -1
- package/package.json +5 -5
- package/dist/commands/chat/create.d.ts +0 -9
- package/dist/commands/chat/create.js +0 -23
- package/dist/commands/chat/delete.d.ts +0 -6
- package/dist/commands/chat/delete.js +0 -20
- package/dist/commands/chat/index.d.ts +0 -3
- package/dist/commands/chat/index.js +0 -47
- package/dist/commands/chat/inspect.d.ts +0 -6
- package/dist/commands/chat/inspect.js +0 -20
- package/dist/commands/chat/ls.d.ts +0 -6
- package/dist/commands/chat/ls.js +0 -20
- package/dist/commands/chat/post.d.ts +0 -9
- package/dist/commands/chat/post.js +0 -28
- package/dist/commands/chat/read.d.ts +0 -11
- package/dist/commands/chat/read.js +0 -40
- package/dist/commands/chat/schema.d.ts +0 -36
- package/dist/commands/chat/schema.js +0 -81
- package/dist/commands/chat/shared.d.ts +0 -19
- package/dist/commands/chat/shared.js +0 -118
- package/dist/commands/chat/wait.d.ts +0 -9
- package/dist/commands/chat/wait.js +0 -45
- package/dist/commands/hub/cloud-device-authorization.d.ts +0 -45
- package/dist/commands/hub/cloud-device-authorization.js +0 -92
- package/dist/commands/hub/device-authorization.js +0 -87
- package/dist/commands/loop/index.d.ts +0 -3
- package/dist/commands/loop/index.js +0 -18
- package/dist/commands/loop/inspect.d.ts +0 -13
- package/dist/commands/loop/inspect.js +0 -100
- package/dist/commands/loop/logs.d.ts +0 -8
- package/dist/commands/loop/logs.js +0 -69
- package/dist/commands/loop/ls.d.ts +0 -18
- package/dist/commands/loop/ls.js +0 -62
- package/dist/commands/loop/run.d.ts +0 -30
- package/dist/commands/loop/run.js +0 -162
- package/dist/commands/loop/stop.d.ts +0 -15
- package/dist/commands/loop/stop.js +0 -56
- package/dist/commands/loop/types.d.ts +0 -138
- package/dist/commands/loop/types.js +0 -2
|
@@ -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.ottoDirectoryExists,
|
|
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: ".otto/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: `.otto/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: "@otto", from_users: [user] },
|
|
73
|
+
environment,
|
|
74
|
+
}),
|
|
75
|
+
testAction: `Comment \`@otto 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 `@Otto 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 `@Otto 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\${{ otto.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 Otto Hub");
|
|
48
|
+
const cwd = environment.cwd();
|
|
49
|
+
const activeLogin = environment.credentials.active();
|
|
50
|
+
const opening = planHubInitOpening({
|
|
51
|
+
loggedIn: activeLogin !== null,
|
|
52
|
+
ottoDirectoryExists: await pathExists(path.join(cwd, ".otto")),
|
|
53
|
+
});
|
|
54
|
+
if (opening.replaceExisting &&
|
|
55
|
+
!(await requiredConfirm("Replace the existing .otto/ Hub bundle?", false))) {
|
|
56
|
+
throw new HubInitCancelledError("Existing .otto/ 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 .otto/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: otto 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.otto-code.me" },
|
|
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.OTTO_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 `otto 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 otto 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, ".otto");
|
|
298
|
+
const staging = path.join(cwd, `.otto-init-${randomUUID()}`);
|
|
299
|
+
const backup = path.join(cwd, `.otto-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", "otto hub init requires a TTY.");
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
//# sourceMappingURL=init.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
export interface
|
|
1
|
+
import type { HubHttpClient } from "./hub-client/index.js";
|
|
2
|
+
export interface LoginWaiter {
|
|
3
3
|
wait(milliseconds: number): Promise<void>;
|
|
4
4
|
now(): number;
|
|
5
5
|
}
|
|
@@ -17,21 +17,18 @@ export declare class SystemBrowser implements BrowserOpener {
|
|
|
17
17
|
constructor(options?: SystemBrowserOptions);
|
|
18
18
|
open(url: string): Promise<void>;
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
interface DeviceAuthorizationWorkflowOptions {
|
|
24
|
-
cloud: CloudDeviceAuthorization;
|
|
25
|
-
waiter: AuthorizationWaiter;
|
|
20
|
+
interface CliLoginFlowOptions {
|
|
21
|
+
hub: Pick<HubHttpClient, "startCliAuthorization" | "pollCliAuthorization">;
|
|
22
|
+
waiter: LoginWaiter;
|
|
26
23
|
browser: BrowserOpener;
|
|
27
|
-
|
|
28
|
-
openBrowser
|
|
24
|
+
instructions(verificationUri: string, userCode: string): void;
|
|
25
|
+
openBrowser: boolean;
|
|
29
26
|
}
|
|
30
|
-
export declare class
|
|
27
|
+
export declare class CliLoginFlow {
|
|
31
28
|
private readonly options;
|
|
32
|
-
constructor(options:
|
|
33
|
-
authorize(
|
|
29
|
+
constructor(options: CliLoginFlowOptions);
|
|
30
|
+
authorize(origin: string): Promise<string>;
|
|
34
31
|
}
|
|
35
|
-
export declare function
|
|
32
|
+
export declare function createCliLoginFlow(hub: HubHttpClient): CliLoginFlow;
|
|
36
33
|
export {};
|
|
37
|
-
//# sourceMappingURL=
|
|
34
|
+
//# sourceMappingURL=login-flow.d.ts.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { platform } from "node:os";
|
|
3
|
+
export class SystemBrowser {
|
|
4
|
+
constructor(options = {}) {
|
|
5
|
+
this.hostPlatform = options.hostPlatform ?? platform();
|
|
6
|
+
this.launch = options.launch ?? launchDetached;
|
|
7
|
+
}
|
|
8
|
+
async open(url) {
|
|
9
|
+
if (this.hostPlatform === "win32") {
|
|
10
|
+
await this.launch("rundll32.exe", ["url.dll,FileProtocolHandler", url]);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
await this.launch(this.hostPlatform === "darwin" ? "open" : "xdg-open", [url]);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class CliLoginFlow {
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
}
|
|
20
|
+
async authorize(origin) {
|
|
21
|
+
const authorization = await this.options.hub.startCliAuthorization(origin);
|
|
22
|
+
this.options.instructions(authorization.verificationUri, authorization.userCode);
|
|
23
|
+
if (this.options.openBrowser) {
|
|
24
|
+
await this.options.browser.open(authorization.verificationUriComplete).catch(() => undefined);
|
|
25
|
+
}
|
|
26
|
+
let interval = authorization.interval;
|
|
27
|
+
const expiresAt = Date.parse(authorization.expiresAt);
|
|
28
|
+
while (true) {
|
|
29
|
+
const remaining = expiresAt - this.options.waiter.now();
|
|
30
|
+
if (remaining <= 0)
|
|
31
|
+
throw new Error("Hub CLI login expired");
|
|
32
|
+
await this.options.waiter.wait(Math.min(interval * 1000, remaining));
|
|
33
|
+
const pollLifetime = expiresAt - this.options.waiter.now();
|
|
34
|
+
if (pollLifetime <= 0)
|
|
35
|
+
throw new Error("Hub CLI login expired");
|
|
36
|
+
const outcome = await this.options.hub.pollCliAuthorization(origin, authorization.deviceCode, pollLifetime);
|
|
37
|
+
const credential = approvedCredential(outcome);
|
|
38
|
+
if (credential !== null)
|
|
39
|
+
return credential;
|
|
40
|
+
if (outcome.status === "denied")
|
|
41
|
+
throw new Error("Hub CLI login was denied");
|
|
42
|
+
if (outcome.status === "expired")
|
|
43
|
+
throw new Error("Hub CLI login expired");
|
|
44
|
+
if (outcome.status === "disclosed")
|
|
45
|
+
throw new Error("Hub CLI login was already completed");
|
|
46
|
+
if (outcome.status !== "retry_later")
|
|
47
|
+
interval = outcome.interval;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function createCliLoginFlow(hub) {
|
|
52
|
+
return new CliLoginFlow({
|
|
53
|
+
hub,
|
|
54
|
+
waiter: {
|
|
55
|
+
wait: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
56
|
+
now: Date.now,
|
|
57
|
+
},
|
|
58
|
+
browser: new SystemBrowser(),
|
|
59
|
+
instructions(verificationUri, userCode) {
|
|
60
|
+
process.stderr.write(`Open ${verificationUri} and enter code ${userCode}\n`);
|
|
61
|
+
},
|
|
62
|
+
openBrowser: process.stderr.isTTY === true,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function approvedCredential(outcome) {
|
|
66
|
+
return outcome.status === "authorized" ? outcome.credential : null;
|
|
67
|
+
}
|
|
68
|
+
async function launchDetached(command, args) {
|
|
69
|
+
await new Promise((resolve, reject) => {
|
|
70
|
+
const child = spawn(command, args, { detached: true, shell: false, stdio: "ignore" });
|
|
71
|
+
child.once("spawn", () => {
|
|
72
|
+
child.unref();
|
|
73
|
+
resolve();
|
|
74
|
+
});
|
|
75
|
+
child.once("error", reject);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=login-flow.js.map
|