@tonbo/cli 0.0.1
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/README.md +77 -0
- package/dist/src/api.d.ts +70 -0
- package/dist/src/api.js +179 -0
- package/dist/src/app.d.ts +4 -0
- package/dist/src/app.js +99 -0
- package/dist/src/auth.d.ts +13 -0
- package/dist/src/auth.js +147 -0
- package/dist/src/callback-page.d.ts +23 -0
- package/dist/src/callback-page.js +145 -0
- package/dist/src/commands.d.ts +35 -0
- package/dist/src/commands.js +168 -0
- package/dist/src/config.d.ts +13 -0
- package/dist/src/config.js +46 -0
- package/dist/src/contracts.d.ts +3 -0
- package/dist/src/contracts.js +24 -0
- package/dist/src/credentials.d.ts +17 -0
- package/dist/src/credentials.js +90 -0
- package/dist/src/declaration.d.ts +4 -0
- package/dist/src/declaration.js +32 -0
- package/dist/src/generated/contracts.d.ts +187 -0
- package/dist/src/generated/contracts.js +221 -0
- package/dist/src/http.d.ts +6 -0
- package/dist/src/http.js +19 -0
- package/dist/src/main.d.ts +2 -0
- package/dist/src/main.js +9 -0
- package/dist/src/source.d.ts +8 -0
- package/dist/src/source.js +139 -0
- package/dist/src/ssh-key.d.ts +9 -0
- package/dist/src/ssh-key.js +36 -0
- package/dist/src/ssh.d.ts +3 -0
- package/dist/src/ssh.js +22 -0
- package/dist/src/types.d.ts +85 -0
- package/dist/src/types.js +1 -0
- package/package.json +47 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page a browser lands on after `tonbo login`.
|
|
3
|
+
*
|
|
4
|
+
* This is served by a loopback HTTP server inside the CLI, so it cannot reach
|
|
5
|
+
* the app's stylesheet or webfonts. It is self-contained on purpose: no
|
|
6
|
+
* external request, which also means it renders offline and leaks nothing
|
|
7
|
+
* about the login to a third party.
|
|
8
|
+
*
|
|
9
|
+
* Nothing from the query string is interpolated here. The callback URL is
|
|
10
|
+
* reachable by anything that can make the user's browser open a localhost
|
|
11
|
+
* address, so reflecting `error_description` would put attacker-chosen text
|
|
12
|
+
* into a page served from the user's own machine.
|
|
13
|
+
*/
|
|
14
|
+
// tonbo.io's wordmark. Kept as text so it needs no asset and stays sharp at
|
|
15
|
+
// any density; a monospace stack is the only thing it depends on.
|
|
16
|
+
const WORDMARK = [
|
|
17
|
+
"███▀█████████████▀████████",
|
|
18
|
+
"██▄ ▄██▀▄ ██ ▄▀██ ▄ ██ ▄▀█",
|
|
19
|
+
"███▄▄██▄▄███▄█▄██▄▄████▄▄█",
|
|
20
|
+
].join("\n");
|
|
21
|
+
const OUTCOMES = {
|
|
22
|
+
complete: {
|
|
23
|
+
status: 200,
|
|
24
|
+
title: "You are signed in.",
|
|
25
|
+
body: "Return to your terminal. This tab can be closed.",
|
|
26
|
+
},
|
|
27
|
+
denied: {
|
|
28
|
+
status: 400,
|
|
29
|
+
title: "Login was not completed.",
|
|
30
|
+
body: "Nothing was authorized. Return to your terminal to try again.",
|
|
31
|
+
},
|
|
32
|
+
invalid: {
|
|
33
|
+
// The state did not match, the code was missing, or the path was wrong.
|
|
34
|
+
// Say that it could not be verified rather than which check failed: the
|
|
35
|
+
// person who can read this page is not necessarily the one who started
|
|
36
|
+
// the login.
|
|
37
|
+
status: 400,
|
|
38
|
+
title: "This callback could not be verified.",
|
|
39
|
+
body: "Return to your terminal and start the login again.",
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
export function renderCallbackPage(outcome) {
|
|
43
|
+
const { title, body } = OUTCOMES[outcome];
|
|
44
|
+
return `<!doctype html>
|
|
45
|
+
<html lang="en">
|
|
46
|
+
<head>
|
|
47
|
+
<meta charset="utf-8">
|
|
48
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
49
|
+
<meta name="robots" content="noindex">
|
|
50
|
+
<title>Tonbo CLI</title>
|
|
51
|
+
<style>
|
|
52
|
+
:root {
|
|
53
|
+
--paper: #f3f2ee;
|
|
54
|
+
--ink: #1d1b17;
|
|
55
|
+
--rule: #c9c5ba;
|
|
56
|
+
--quiet: #5c564a;
|
|
57
|
+
--signal: #d64a03;
|
|
58
|
+
}
|
|
59
|
+
@media (prefers-color-scheme: dark) {
|
|
60
|
+
:root {
|
|
61
|
+
--paper: #1d1b17;
|
|
62
|
+
--ink: #f3f2ee;
|
|
63
|
+
--rule: #3a362e;
|
|
64
|
+
--quiet: #a49e8f;
|
|
65
|
+
--signal: #f08b4b;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
* { box-sizing: border-box; }
|
|
69
|
+
body {
|
|
70
|
+
margin: 0;
|
|
71
|
+
min-height: 100vh;
|
|
72
|
+
display: grid;
|
|
73
|
+
grid-template-rows: auto 1fr;
|
|
74
|
+
padding: 0 clamp(20px, 5vw, 72px);
|
|
75
|
+
background: var(--paper);
|
|
76
|
+
color: var(--ink);
|
|
77
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
78
|
+
-webkit-font-smoothing: antialiased;
|
|
79
|
+
}
|
|
80
|
+
header { padding: 28px 0; }
|
|
81
|
+
/* Same metrics as .auth-wordmark in the app: the glyphs are the ground
|
|
82
|
+
and the letters are the gaps, so the 8px/10px/scaleY(0.8) combination is
|
|
83
|
+
what makes the rows meet without seams. Iosevka is not available here,
|
|
84
|
+
so it falls back to the platform monospace. */
|
|
85
|
+
pre.wordmark {
|
|
86
|
+
margin: 0;
|
|
87
|
+
font-family: 'Iosevka', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
|
|
88
|
+
font-size: 8px;
|
|
89
|
+
font-weight: 400;
|
|
90
|
+
line-height: 10px;
|
|
91
|
+
letter-spacing: 0;
|
|
92
|
+
white-space: pre;
|
|
93
|
+
color: var(--signal);
|
|
94
|
+
transform: scaleY(0.8);
|
|
95
|
+
transform-origin: left top;
|
|
96
|
+
}
|
|
97
|
+
main {
|
|
98
|
+
display: flex;
|
|
99
|
+
flex-direction: column;
|
|
100
|
+
justify-content: center;
|
|
101
|
+
max-width: 46ch;
|
|
102
|
+
padding-bottom: 12vh;
|
|
103
|
+
}
|
|
104
|
+
h1 {
|
|
105
|
+
margin: 0;
|
|
106
|
+
font-size: 26px;
|
|
107
|
+
font-weight: 300;
|
|
108
|
+
line-height: 1.16;
|
|
109
|
+
letter-spacing: -0.018em;
|
|
110
|
+
}
|
|
111
|
+
p {
|
|
112
|
+
margin: 14px 0 0;
|
|
113
|
+
font-size: 16px;
|
|
114
|
+
line-height: 1.55;
|
|
115
|
+
color: var(--quiet);
|
|
116
|
+
}
|
|
117
|
+
hr {
|
|
118
|
+
width: 40px;
|
|
119
|
+
margin: 28px 0 0;
|
|
120
|
+
border: 0;
|
|
121
|
+
border-top: 1px solid var(--rule);
|
|
122
|
+
}
|
|
123
|
+
</style>
|
|
124
|
+
</head>
|
|
125
|
+
<body>
|
|
126
|
+
<header><pre class="wordmark" aria-hidden="true">${WORDMARK}</pre></header>
|
|
127
|
+
<main>
|
|
128
|
+
<h1>${title}</h1>
|
|
129
|
+
<p>${body}</p>
|
|
130
|
+
<hr>
|
|
131
|
+
</main>
|
|
132
|
+
</body>
|
|
133
|
+
</html>
|
|
134
|
+
`;
|
|
135
|
+
}
|
|
136
|
+
export function callbackResponse(outcome) {
|
|
137
|
+
return {
|
|
138
|
+
status: OUTCOMES[outcome].status,
|
|
139
|
+
headers: {
|
|
140
|
+
"content-type": "text/html; charset=utf-8",
|
|
141
|
+
"cache-control": "no-store",
|
|
142
|
+
},
|
|
143
|
+
body: renderCallbackPage(outcome),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AuthClient } from "./auth.js";
|
|
2
|
+
import type { TonboApi } from "./api.js";
|
|
3
|
+
import type { ConfigStore } from "./config.js";
|
|
4
|
+
import type { ProjectSummary } from "./types.js";
|
|
5
|
+
export interface CommandDependencies {
|
|
6
|
+
api: TonboApi;
|
|
7
|
+
auth: AuthClient;
|
|
8
|
+
config: ConfigStore;
|
|
9
|
+
cwd: () => string;
|
|
10
|
+
output: (value: unknown) => void;
|
|
11
|
+
executable?: () => string;
|
|
12
|
+
secretValue: (name: string, fromEnvironment?: string) => Promise<string>;
|
|
13
|
+
}
|
|
14
|
+
export declare function resolveProject(deps: CommandDependencies, selector?: string): Promise<{
|
|
15
|
+
oauthToken: string;
|
|
16
|
+
project: ProjectSummary;
|
|
17
|
+
}>;
|
|
18
|
+
export declare function selectProject(projects: ProjectSummary[], selector: string): ProjectSummary;
|
|
19
|
+
export declare function loginCommand(deps: CommandDependencies): Promise<void>;
|
|
20
|
+
export declare function sshKeyAddCommand(deps: CommandDependencies, path: string): Promise<void>;
|
|
21
|
+
export declare function sshKeyRemoveCommand(deps: CommandDependencies, fingerprint: string): Promise<void>;
|
|
22
|
+
export declare function projectUseCommand(deps: CommandDependencies, selector: string): Promise<void>;
|
|
23
|
+
export declare function projectCreateCommand(deps: CommandDependencies, slug: string, name?: string): Promise<void>;
|
|
24
|
+
export declare function deployCommand(deps: CommandDependencies, selector?: string): Promise<void>;
|
|
25
|
+
export declare function runCommand(deps: CommandDependencies, prompt: string, options: {
|
|
26
|
+
project?: string;
|
|
27
|
+
session?: string;
|
|
28
|
+
}): Promise<void>;
|
|
29
|
+
export declare function sshCommand(deps: CommandDependencies, selector?: string): Promise<void>;
|
|
30
|
+
export declare function secretListCommand(deps: CommandDependencies, selector?: string): Promise<void>;
|
|
31
|
+
export declare function secretSetCommand(deps: CommandDependencies, name: string, options: {
|
|
32
|
+
fromEnv?: string;
|
|
33
|
+
project?: string;
|
|
34
|
+
}): Promise<void>;
|
|
35
|
+
export declare function secretRemoveCommand(deps: CommandDependencies, name: string, selector?: string): Promise<void>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { buildRevision, loadDeclaration } from "./declaration.js";
|
|
2
|
+
import { buildSourceBundle, findDeclarationRoot } from "./source.js";
|
|
3
|
+
import { readDefaultSshPublicKeys, readSshPublicKey } from "./ssh-key.js";
|
|
4
|
+
import { launchProjectSsh } from "./ssh.js";
|
|
5
|
+
export async function resolveProject(deps, selector) {
|
|
6
|
+
const oauthToken = await deps.auth.accessToken();
|
|
7
|
+
if (selector) {
|
|
8
|
+
const normalized = selector.endsWith(".tonbo.sh")
|
|
9
|
+
? selector.slice(0, -".tonbo.sh".length)
|
|
10
|
+
: selector;
|
|
11
|
+
return {
|
|
12
|
+
oauthToken,
|
|
13
|
+
project: selectProject(await deps.api.listProjects(oauthToken), normalized),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const root = await findDeclarationRoot(deps.cwd());
|
|
17
|
+
const binding = await deps.config.getBinding(root);
|
|
18
|
+
if (!binding)
|
|
19
|
+
throw new Error("No Project selected. Run `tonbo project use <project>` first.");
|
|
20
|
+
return {
|
|
21
|
+
oauthToken,
|
|
22
|
+
project: {
|
|
23
|
+
id: binding.projectId,
|
|
24
|
+
slug: binding.projectSlug,
|
|
25
|
+
status: "active",
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function selectProject(projects, selector) {
|
|
30
|
+
const matches = projects.filter((project) => project.id === selector || project.slug === selector);
|
|
31
|
+
if (matches.length === 0)
|
|
32
|
+
throw new Error(`Project ${selector} was not found in your account.`);
|
|
33
|
+
if (matches.length > 1)
|
|
34
|
+
throw new Error(`Project slug ${selector} is ambiguous; use its ID.`);
|
|
35
|
+
if (matches[0].status !== "active")
|
|
36
|
+
throw new Error(`Project ${selector} is not active.`);
|
|
37
|
+
return matches[0];
|
|
38
|
+
}
|
|
39
|
+
export async function loginCommand(deps) {
|
|
40
|
+
await deps.auth.login();
|
|
41
|
+
const oauthToken = await deps.auth.accessToken();
|
|
42
|
+
const keys = await readDefaultSshPublicKeys();
|
|
43
|
+
for (const key of keys)
|
|
44
|
+
await deps.api.registerSshKey(oauthToken, key);
|
|
45
|
+
deps.output({
|
|
46
|
+
message: keys.length
|
|
47
|
+
? `Logged in to Tonbo and registered ${keys.length} SSH key${keys.length === 1 ? "" : "s"}.`
|
|
48
|
+
: "Logged in to Tonbo. No default SSH public key was found; run `tonbo ssh-key add <path.pub>` before using native SSH.",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export async function sshKeyAddCommand(deps, path) {
|
|
52
|
+
const key = await readSshPublicKey(path);
|
|
53
|
+
const oauthToken = await deps.auth.accessToken();
|
|
54
|
+
await deps.api.registerSshKey(oauthToken, key);
|
|
55
|
+
deps.output({ message: `Registered SSH key ${key.fingerprint}.`, key });
|
|
56
|
+
}
|
|
57
|
+
export async function sshKeyRemoveCommand(deps, fingerprint) {
|
|
58
|
+
const oauthToken = await deps.auth.accessToken();
|
|
59
|
+
const key = await deps.api.revokeSshKey(oauthToken, fingerprint);
|
|
60
|
+
deps.output({ message: `Revoked SSH key ${key.fingerprint}.`, key });
|
|
61
|
+
}
|
|
62
|
+
export async function projectUseCommand(deps, selector) {
|
|
63
|
+
const root = await findDeclarationRoot(deps.cwd());
|
|
64
|
+
const oauthToken = await deps.auth.accessToken();
|
|
65
|
+
const project = selectProject(await deps.api.listProjects(oauthToken), selector);
|
|
66
|
+
await deps.config.setBinding(root, {
|
|
67
|
+
projectId: project.id,
|
|
68
|
+
projectSlug: project.slug,
|
|
69
|
+
});
|
|
70
|
+
deps.output({ message: `Using Project ${project.slug}.`, project });
|
|
71
|
+
}
|
|
72
|
+
export async function projectCreateCommand(deps, slug, name) {
|
|
73
|
+
const root = await findDeclarationRoot(deps.cwd());
|
|
74
|
+
const oauthToken = await deps.auth.accessToken();
|
|
75
|
+
const project = await deps.api.createProject(oauthToken, slug, name);
|
|
76
|
+
await deps.config.setBinding(root, {
|
|
77
|
+
projectId: project.id,
|
|
78
|
+
projectSlug: project.slug,
|
|
79
|
+
});
|
|
80
|
+
deps.output({ message: `Created and selected Project ${project.slug}.`, project });
|
|
81
|
+
}
|
|
82
|
+
export async function deployCommand(deps, selector) {
|
|
83
|
+
const root = await findDeclarationRoot(deps.cwd());
|
|
84
|
+
const declaration = await loadDeclaration(root);
|
|
85
|
+
const source = await buildSourceBundle(root);
|
|
86
|
+
const oauthToken = await deps.auth.accessToken();
|
|
87
|
+
let binding = await deps.config.getBinding(root);
|
|
88
|
+
if (selector) {
|
|
89
|
+
const project = selectProject(await deps.api.listProjects(oauthToken), selector);
|
|
90
|
+
binding = { projectId: project.id, projectSlug: project.slug };
|
|
91
|
+
}
|
|
92
|
+
if (!binding)
|
|
93
|
+
throw new Error("No Project selected. Run `tonbo project use <project>` first.");
|
|
94
|
+
const managementToken = await deps.api.exchangeManagementToken(oauthToken, binding.projectId);
|
|
95
|
+
const result = await deps.api.deploy({
|
|
96
|
+
bundle: source,
|
|
97
|
+
projectId: binding.projectId,
|
|
98
|
+
spec: buildRevision(declaration, source),
|
|
99
|
+
token: managementToken,
|
|
100
|
+
});
|
|
101
|
+
deps.output({
|
|
102
|
+
message: `Deployed Project ${binding.projectSlug}.`,
|
|
103
|
+
project: binding,
|
|
104
|
+
...result,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
export async function runCommand(deps, prompt, options) {
|
|
108
|
+
const root = await findDeclarationRoot(deps.cwd());
|
|
109
|
+
const oauthToken = await deps.auth.accessToken();
|
|
110
|
+
let binding = await deps.config.getBinding(root);
|
|
111
|
+
if (options.project) {
|
|
112
|
+
const project = selectProject(await deps.api.listProjects(oauthToken), options.project);
|
|
113
|
+
binding = { projectId: project.id, projectSlug: project.slug };
|
|
114
|
+
}
|
|
115
|
+
if (!binding)
|
|
116
|
+
throw new Error("No Project selected. Run `tonbo project use <project>` first.");
|
|
117
|
+
const managementToken = await deps.api.exchangeManagementToken(oauthToken, binding.projectId);
|
|
118
|
+
const result = await deps.api.run({
|
|
119
|
+
projectId: binding.projectId,
|
|
120
|
+
prompt,
|
|
121
|
+
sessionId: options.session,
|
|
122
|
+
token: managementToken,
|
|
123
|
+
});
|
|
124
|
+
deps.output({
|
|
125
|
+
message: result.turn.assistant_text,
|
|
126
|
+
project: binding,
|
|
127
|
+
...result,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
export async function sshCommand(deps, selector) {
|
|
131
|
+
const { project } = await resolveProject(deps, selector);
|
|
132
|
+
const code = await launchProjectSsh(project.slug);
|
|
133
|
+
if (code !== 0)
|
|
134
|
+
throw new Error(`ssh exited with status ${code}`);
|
|
135
|
+
}
|
|
136
|
+
async function projectManagement(deps, selector) {
|
|
137
|
+
const { oauthToken, project } = await resolveProject(deps, selector);
|
|
138
|
+
return {
|
|
139
|
+
project,
|
|
140
|
+
token: await deps.api.exchangeManagementToken(oauthToken, project.id),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
export async function secretListCommand(deps, selector) {
|
|
144
|
+
const { project, token } = await projectManagement(deps, selector);
|
|
145
|
+
const secrets = await deps.api.listProjectSecrets(project.id, token);
|
|
146
|
+
deps.output({
|
|
147
|
+
message: secrets.length
|
|
148
|
+
? secrets.map((secret) => secret.name).join("\n")
|
|
149
|
+
: "No Project secrets are configured.",
|
|
150
|
+
project,
|
|
151
|
+
secrets,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
export async function secretSetCommand(deps, name, options) {
|
|
155
|
+
const value = await deps.secretValue(name, options.fromEnv);
|
|
156
|
+
const { project, token } = await projectManagement(deps, options.project);
|
|
157
|
+
const secret = await deps.api.setProjectSecret(project.id, name, value, token);
|
|
158
|
+
deps.output({
|
|
159
|
+
message: `Set Project secret ${secret.name}. Redeploy to replace the active runtime with this value.`,
|
|
160
|
+
project,
|
|
161
|
+
secret,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
export async function secretRemoveCommand(deps, name, selector) {
|
|
165
|
+
const { project, token } = await projectManagement(deps, selector);
|
|
166
|
+
await deps.api.deleteProjectSecret(project.id, name, token);
|
|
167
|
+
deps.output({ message: `Removed Project secret ${name}.`, project, name });
|
|
168
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ProjectBinding } from "./types.js";
|
|
2
|
+
export interface ConfigStore {
|
|
3
|
+
getBinding(declarationRoot: string): Promise<ProjectBinding | null>;
|
|
4
|
+
setBinding(declarationRoot: string, binding: ProjectBinding): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export declare class FileConfigStore implements ConfigStore {
|
|
7
|
+
private readonly filename;
|
|
8
|
+
constructor(filename?: string);
|
|
9
|
+
getBinding(declarationRoot: string): Promise<ProjectBinding>;
|
|
10
|
+
setBinding(declarationRoot: string, binding: ProjectBinding): Promise<void>;
|
|
11
|
+
private read;
|
|
12
|
+
}
|
|
13
|
+
export declare function defaultConfigDirectory(): string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export class FileConfigStore {
|
|
5
|
+
filename;
|
|
6
|
+
constructor(filename = defaultConfigPath()) {
|
|
7
|
+
this.filename = filename;
|
|
8
|
+
}
|
|
9
|
+
async getBinding(declarationRoot) {
|
|
10
|
+
const config = await this.read();
|
|
11
|
+
return config.bindings[path.resolve(declarationRoot)] ?? null;
|
|
12
|
+
}
|
|
13
|
+
async setBinding(declarationRoot, binding) {
|
|
14
|
+
const config = await this.read();
|
|
15
|
+
config.bindings[path.resolve(declarationRoot)] = binding;
|
|
16
|
+
await mkdir(path.dirname(this.filename), { recursive: true, mode: 0o700 });
|
|
17
|
+
await writeFile(this.filename, `${JSON.stringify(config, null, 2)}\n`, {
|
|
18
|
+
mode: 0o600,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async read() {
|
|
22
|
+
try {
|
|
23
|
+
const value = JSON.parse(await readFile(this.filename, "utf8"));
|
|
24
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
25
|
+
throw new Error();
|
|
26
|
+
const bindings = value.bindings;
|
|
27
|
+
if (!bindings || typeof bindings !== "object" || Array.isArray(bindings))
|
|
28
|
+
throw new Error();
|
|
29
|
+
return { bindings: bindings };
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error.code === "ENOENT")
|
|
33
|
+
return { bindings: {} };
|
|
34
|
+
throw new Error(`Could not read Tonbo config at ${this.filename}.`, {
|
|
35
|
+
cause: error,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function defaultConfigPath() {
|
|
41
|
+
return path.join(defaultConfigDirectory(), "config.json");
|
|
42
|
+
}
|
|
43
|
+
export function defaultConfigDirectory() {
|
|
44
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
45
|
+
return path.join(base, "tonbo");
|
|
46
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
2
|
+
import { declarationSchema, kubernetesProfilesSchema, projectServiceSchema, revisionSchema, } from "./generated/contracts.js";
|
|
3
|
+
const ajv = new Ajv2020({ allErrors: true, useDefaults: true });
|
|
4
|
+
ajv.addKeyword({ keyword: "x-tonbo-profiles" });
|
|
5
|
+
ajv.addSchema(kubernetesProfilesSchema);
|
|
6
|
+
ajv.addSchema(projectServiceSchema);
|
|
7
|
+
const validateDeclaration = ajv.compile(declarationSchema);
|
|
8
|
+
const validateRevision = ajv.compile(revisionSchema);
|
|
9
|
+
function validationMessage(label, errors) {
|
|
10
|
+
const detail = errors?.map((error) => `${error.instancePath || "/"} ${error.message}`).join("; ");
|
|
11
|
+
return `${label} is invalid${detail ? `: ${detail}` : "."}`;
|
|
12
|
+
}
|
|
13
|
+
export function parseDeclaration(value) {
|
|
14
|
+
const candidate = structuredClone(value);
|
|
15
|
+
if (!validateDeclaration(candidate)) {
|
|
16
|
+
throw new Error(validationMessage(".tonbo", validateDeclaration.errors));
|
|
17
|
+
}
|
|
18
|
+
return candidate;
|
|
19
|
+
}
|
|
20
|
+
export function assertManagedRevision(value) {
|
|
21
|
+
if (!validateRevision(value)) {
|
|
22
|
+
throw new Error(validationMessage("Managed revision", validateRevision.errors));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OAuthTokenSet } from "./types.js";
|
|
2
|
+
export interface CredentialStore {
|
|
3
|
+
load(): Promise<OAuthTokenSet | null>;
|
|
4
|
+
save(tokens: OAuthTokenSet): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Human OAuth credentials live beside the CLI config, never in an Agent
|
|
8
|
+
* directory. The file is private and atomically replaced so a crash cannot
|
|
9
|
+
* leave a partially rotated refresh token. Symlinks are refused because this
|
|
10
|
+
* path contains bearer credentials and must not redirect writes elsewhere.
|
|
11
|
+
*/
|
|
12
|
+
export declare class FileCredentialStore implements CredentialStore {
|
|
13
|
+
private readonly filename;
|
|
14
|
+
constructor(filename?: string);
|
|
15
|
+
load(): Promise<OAuthTokenSet | null>;
|
|
16
|
+
save(tokens: OAuthTokenSet): Promise<void>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { defaultConfigDirectory } from "./config.js";
|
|
5
|
+
/**
|
|
6
|
+
* Human OAuth credentials live beside the CLI config, never in an Agent
|
|
7
|
+
* directory. The file is private and atomically replaced so a crash cannot
|
|
8
|
+
* leave a partially rotated refresh token. Symlinks are refused because this
|
|
9
|
+
* path contains bearer credentials and must not redirect writes elsewhere.
|
|
10
|
+
*/
|
|
11
|
+
export class FileCredentialStore {
|
|
12
|
+
filename;
|
|
13
|
+
constructor(filename = path.join(defaultConfigDirectory(), "credentials.json")) {
|
|
14
|
+
this.filename = filename;
|
|
15
|
+
}
|
|
16
|
+
async load() {
|
|
17
|
+
try {
|
|
18
|
+
await assertPrivateRegularFile(this.filename);
|
|
19
|
+
const parsed = JSON.parse(await readFile(this.filename, "utf8"));
|
|
20
|
+
if (!isOAuthTokenSet(parsed))
|
|
21
|
+
throw new Error("invalid token set");
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code === "ENOENT")
|
|
26
|
+
return null;
|
|
27
|
+
throw new Error(`Could not read Tonbo credentials at ${this.filename}.`, { cause: error });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async save(tokens) {
|
|
31
|
+
if (!isOAuthTokenSet(tokens))
|
|
32
|
+
throw new Error("Refusing to store a malformed Tonbo token set.");
|
|
33
|
+
const directory = path.dirname(this.filename);
|
|
34
|
+
await mkdir(directory, { mode: 0o700, recursive: true });
|
|
35
|
+
await preparePrivateDirectory(directory);
|
|
36
|
+
await assertExistingDestinationIsSafe(this.filename);
|
|
37
|
+
const temporary = path.join(directory, `.${path.basename(this.filename)}.${process.pid}.${randomUUID()}.tmp`);
|
|
38
|
+
let handle = null;
|
|
39
|
+
try {
|
|
40
|
+
handle = await open(temporary, "wx", 0o600);
|
|
41
|
+
await handle.writeFile(`${JSON.stringify(tokens, null, 2)}\n`, "utf8");
|
|
42
|
+
await handle.sync();
|
|
43
|
+
await handle.close();
|
|
44
|
+
handle = null;
|
|
45
|
+
if (process.platform !== "win32")
|
|
46
|
+
await chmod(temporary, 0o600);
|
|
47
|
+
await rename(temporary, this.filename);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
await handle?.close().catch(() => undefined);
|
|
51
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
52
|
+
throw new Error(`Could not store Tonbo credentials at ${this.filename}.`, { cause: error });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function isOAuthTokenSet(value) {
|
|
57
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
58
|
+
return false;
|
|
59
|
+
const token = value;
|
|
60
|
+
return (typeof token.access_token === "string" &&
|
|
61
|
+
token.access_token.length > 0 &&
|
|
62
|
+
(token.refresh_token === undefined || typeof token.refresh_token === "string") &&
|
|
63
|
+
(token.expires_at === undefined ||
|
|
64
|
+
(Number.isInteger(token.expires_at) && (token.expires_at ?? 0) > 0)));
|
|
65
|
+
}
|
|
66
|
+
async function preparePrivateDirectory(directory) {
|
|
67
|
+
const stat = await lstat(directory);
|
|
68
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
69
|
+
throw new Error("Tonbo config directory must be a real directory.");
|
|
70
|
+
if (process.platform !== "win32")
|
|
71
|
+
await chmod(directory, 0o700);
|
|
72
|
+
}
|
|
73
|
+
async function assertExistingDestinationIsSafe(filename) {
|
|
74
|
+
try {
|
|
75
|
+
const stat = await lstat(filename);
|
|
76
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
77
|
+
throw new Error("Tonbo credential path must be a regular file.");
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error.code !== "ENOENT")
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function assertPrivateRegularFile(filename) {
|
|
85
|
+
const stat = await lstat(filename);
|
|
86
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
87
|
+
throw new Error("Tonbo credential path must be a regular file.");
|
|
88
|
+
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0)
|
|
89
|
+
throw new Error("Tonbo credential file must have mode 0600.");
|
|
90
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { parseDeclaration } from "./contracts.js";
|
|
2
|
+
import type { ManagedRevisionSpec, SourceBundle } from "./types.js";
|
|
3
|
+
export declare function loadDeclaration(declarationRoot: string): Promise<import("./types.js").TonboDeclaration>;
|
|
4
|
+
export declare function buildRevision(declaration: ReturnType<typeof parseDeclaration>, source: SourceBundle): ManagedRevisionSpec;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { assertManagedRevision, parseDeclaration } from "./contracts.js";
|
|
4
|
+
export async function loadDeclaration(declarationRoot) {
|
|
5
|
+
const filename = path.join(declarationRoot, ".tonbo");
|
|
6
|
+
let parsed;
|
|
7
|
+
try {
|
|
8
|
+
parsed = JSON.parse(await readFile(filename, "utf8"));
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error.code === "ENOENT")
|
|
12
|
+
throw new Error(`No .tonbo declaration found at ${filename}.`);
|
|
13
|
+
throw new Error(`Could not read ${filename} as JSON.`, { cause: error });
|
|
14
|
+
}
|
|
15
|
+
return parseDeclaration(parsed);
|
|
16
|
+
}
|
|
17
|
+
export function buildRevision(declaration, source) {
|
|
18
|
+
const spec = {
|
|
19
|
+
version: 1,
|
|
20
|
+
execution: declaration.execution,
|
|
21
|
+
source: {
|
|
22
|
+
format: source.format,
|
|
23
|
+
sha256: source.sha256,
|
|
24
|
+
size_bytes: source.size_bytes,
|
|
25
|
+
},
|
|
26
|
+
inference: declaration.inference,
|
|
27
|
+
session_capture: { adapter: declaration.session_capture.adapter },
|
|
28
|
+
...(declaration.service ? { service: declaration.service } : {}),
|
|
29
|
+
};
|
|
30
|
+
assertManagedRevision(spec);
|
|
31
|
+
return spec;
|
|
32
|
+
}
|