@cardstack/boxel-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/LICENSE +21 -0
- package/README.md +111 -0
- package/api.ts +24 -0
- package/dist/index.js +75 -0
- package/package.json +78 -0
- package/src/commands/profile.ts +457 -0
- package/src/commands/realm/create.ts +245 -0
- package/src/commands/realm/index.ts +16 -0
- package/src/commands/realm/pull.ts +245 -0
- package/src/commands/realm/push.ts +379 -0
- package/src/commands/realm/sync.ts +587 -0
- package/src/commands/run-command.ts +186 -0
- package/src/index.ts +47 -0
- package/src/lib/auth.ts +169 -0
- package/src/lib/boxel-cli-client.ts +631 -0
- package/src/lib/checkpoint-manager.ts +609 -0
- package/src/lib/colors.ts +9 -0
- package/src/lib/profile-manager.ts +583 -0
- package/src/lib/realm-sync-base.ts +647 -0
- package/src/lib/sync-logic.ts +169 -0
- package/src/lib/sync-manifest.ts +81 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { getProfileManager, type ProfileManager } from '../lib/profile-manager';
|
|
3
|
+
import { FG_GREEN, FG_RED, FG_CYAN, DIM, RESET } from '../lib/colors';
|
|
4
|
+
|
|
5
|
+
export interface RunCommandResult {
|
|
6
|
+
status: 'ready' | 'error' | 'unusable';
|
|
7
|
+
result?: string | null;
|
|
8
|
+
error?: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RunCommandOptions {
|
|
12
|
+
input?: Record<string, unknown>;
|
|
13
|
+
json?: boolean;
|
|
14
|
+
profileManager?: ProfileManager;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RunCommandCliOptions {
|
|
18
|
+
realm: string;
|
|
19
|
+
input?: string;
|
|
20
|
+
json?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runCommand(
|
|
24
|
+
commandSpecifier: string,
|
|
25
|
+
realmUrl: string,
|
|
26
|
+
options?: RunCommandOptions,
|
|
27
|
+
): Promise<RunCommandResult> {
|
|
28
|
+
let pm = options?.profileManager ?? getProfileManager();
|
|
29
|
+
let active = pm.getActiveProfile();
|
|
30
|
+
if (!active) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
'No active profile. Run `boxel profile add` to create one.',
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
|
|
37
|
+
let url = `${realmServerUrl}/_run-command`;
|
|
38
|
+
|
|
39
|
+
let body = {
|
|
40
|
+
data: {
|
|
41
|
+
type: 'run-command',
|
|
42
|
+
attributes: {
|
|
43
|
+
realmURL: realmUrl,
|
|
44
|
+
command: commandSpecifier,
|
|
45
|
+
commandInput: options?.input ?? null,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let response: Response;
|
|
51
|
+
try {
|
|
52
|
+
response = await pm.authedRealmServerFetch(url, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
'Content-Type': 'application/vnd.api+json',
|
|
56
|
+
Accept: 'application/vnd.api+json',
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify(body),
|
|
59
|
+
});
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return {
|
|
62
|
+
status: 'error',
|
|
63
|
+
error: `run-command fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
let text = await response.text().catch(() => '(no body)');
|
|
69
|
+
return {
|
|
70
|
+
status: 'error',
|
|
71
|
+
error: `run-command HTTP ${response.status}: ${text}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let json: {
|
|
76
|
+
data?: {
|
|
77
|
+
attributes?: {
|
|
78
|
+
status?: string;
|
|
79
|
+
cardResultString?: string | null;
|
|
80
|
+
error?: string | null;
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
json = await response.json();
|
|
87
|
+
} catch {
|
|
88
|
+
return {
|
|
89
|
+
status: 'error',
|
|
90
|
+
error: `run-command response was not valid JSON (HTTP ${response.status})`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let attrs = json.data?.attributes;
|
|
95
|
+
return {
|
|
96
|
+
status: (attrs?.status as RunCommandResult['status']) ?? 'error',
|
|
97
|
+
result: attrs?.cardResultString ?? null,
|
|
98
|
+
error: attrs?.error ?? null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function registerRunCommand(program: Command): void {
|
|
103
|
+
program
|
|
104
|
+
.command('run-command')
|
|
105
|
+
.description(
|
|
106
|
+
'Execute a host command on the realm server via the prerenderer',
|
|
107
|
+
)
|
|
108
|
+
.argument(
|
|
109
|
+
'<command-specifier>',
|
|
110
|
+
'Command module path (e.g. @cardstack/boxel-host/commands/get-card-type-schema/default)',
|
|
111
|
+
)
|
|
112
|
+
.requiredOption(
|
|
113
|
+
'--realm <realm-url>',
|
|
114
|
+
'The realm URL context for the command',
|
|
115
|
+
)
|
|
116
|
+
.option('--input <json>', 'JSON string of command input')
|
|
117
|
+
.option('--json', 'Output raw JSON response')
|
|
118
|
+
.action(async (commandSpecifier: string, opts: RunCommandCliOptions) => {
|
|
119
|
+
let input: Record<string, unknown> | undefined;
|
|
120
|
+
if (opts.input) {
|
|
121
|
+
try {
|
|
122
|
+
let parsed = JSON.parse(opts.input);
|
|
123
|
+
if (
|
|
124
|
+
typeof parsed !== 'object' ||
|
|
125
|
+
parsed === null ||
|
|
126
|
+
Array.isArray(parsed)
|
|
127
|
+
) {
|
|
128
|
+
console.error(
|
|
129
|
+
`${FG_RED}Error:${RESET} --input must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`,
|
|
130
|
+
);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
input = parsed;
|
|
134
|
+
} catch {
|
|
135
|
+
console.error(
|
|
136
|
+
`${FG_RED}Error:${RESET} --input is not valid JSON: ${opts.input}`,
|
|
137
|
+
);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let result: RunCommandResult;
|
|
143
|
+
try {
|
|
144
|
+
result = await runCommand(commandSpecifier, opts.realm, { input });
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error(
|
|
147
|
+
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
|
|
148
|
+
);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (opts.json) {
|
|
153
|
+
console.log(JSON.stringify(result, null, 2));
|
|
154
|
+
} else {
|
|
155
|
+
console.log(
|
|
156
|
+
`${DIM}Status:${RESET} ${statusColor(result.status)}${result.status}${RESET}`,
|
|
157
|
+
);
|
|
158
|
+
if (result.result) {
|
|
159
|
+
console.log(`${DIM}Result:${RESET}`);
|
|
160
|
+
try {
|
|
161
|
+
console.log(JSON.stringify(JSON.parse(result.result), null, 2));
|
|
162
|
+
} catch {
|
|
163
|
+
console.log(result.result);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (result.error) {
|
|
167
|
+
console.error(`${FG_RED}Error:${RESET} ${result.error}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (result.status === 'error' || result.status === 'unusable') {
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function statusColor(status: string): string {
|
|
178
|
+
switch (status) {
|
|
179
|
+
case 'ready':
|
|
180
|
+
return FG_GREEN;
|
|
181
|
+
case 'error':
|
|
182
|
+
return FG_RED;
|
|
183
|
+
default:
|
|
184
|
+
return FG_CYAN;
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { readFileSync } from 'fs';
|
|
4
|
+
import { resolve } from 'path';
|
|
5
|
+
import { profileCommand } from './commands/profile';
|
|
6
|
+
import { registerRealmCommand } from './commands/realm/index';
|
|
7
|
+
import { registerRunCommand } from './commands/run-command';
|
|
8
|
+
|
|
9
|
+
const pkg = JSON.parse(
|
|
10
|
+
readFileSync(resolve(__dirname, '../package.json'), 'utf-8'),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const program = new Command();
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name('boxel')
|
|
17
|
+
.description('CLI tools for Boxel workspace management')
|
|
18
|
+
.version(pkg.version);
|
|
19
|
+
|
|
20
|
+
program
|
|
21
|
+
.command('profile')
|
|
22
|
+
.description('Manage saved profiles for different users/environments')
|
|
23
|
+
.argument('[subcommand]', 'list | add | switch | remove | migrate')
|
|
24
|
+
.argument('[arg]', 'Profile ID (for switch/remove)')
|
|
25
|
+
.option('-u, --user <matrixId>', 'Matrix user ID (e.g., @user:boxel.ai)')
|
|
26
|
+
.option('-p, --password <password>', 'Password (for add command)')
|
|
27
|
+
.option('-n, --name <displayName>', 'Display name (for add command)')
|
|
28
|
+
.action(
|
|
29
|
+
async (
|
|
30
|
+
subcommand?: string,
|
|
31
|
+
arg?: string,
|
|
32
|
+
options?: { user?: string; password?: string; name?: string },
|
|
33
|
+
) => {
|
|
34
|
+
if (options?.password) {
|
|
35
|
+
console.warn(
|
|
36
|
+
'Warning: Supplying a password via -p/--password may expose it in shell history and process listings. ' +
|
|
37
|
+
'For non-interactive usage, prefer the BOXEL_PASSWORD environment variable or use "boxel profile add" interactively.',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
await profileCommand(subcommand, arg, options);
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
registerRealmCommand(program);
|
|
45
|
+
registerRunCommand(program);
|
|
46
|
+
|
|
47
|
+
program.parse();
|
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
export interface MatrixAuth {
|
|
2
|
+
accessToken: string;
|
|
3
|
+
deviceId: string;
|
|
4
|
+
userId: string;
|
|
5
|
+
matrixUrl: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type RealmTokens = Record<string, string>;
|
|
9
|
+
|
|
10
|
+
interface MatrixLoginResponse {
|
|
11
|
+
access_token: string;
|
|
12
|
+
device_id: string;
|
|
13
|
+
user_id: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
import { APP_BOXEL_REALMS_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants';
|
|
17
|
+
|
|
18
|
+
export async function matrixLogin(
|
|
19
|
+
matrixUrl: string,
|
|
20
|
+
username: string,
|
|
21
|
+
password: string,
|
|
22
|
+
): Promise<MatrixAuth> {
|
|
23
|
+
let response = await fetch(
|
|
24
|
+
new URL('_matrix/client/v3/login', matrixUrl).href,
|
|
25
|
+
{
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: { 'Content-Type': 'application/json' },
|
|
28
|
+
body: JSON.stringify({
|
|
29
|
+
identifier: { type: 'm.id.user', user: username },
|
|
30
|
+
password,
|
|
31
|
+
type: 'm.login.password',
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
let json = (await response.json()) as MatrixLoginResponse;
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Matrix login failed: ${response.status} ${JSON.stringify(json)}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
accessToken: json.access_token,
|
|
45
|
+
deviceId: json.device_id,
|
|
46
|
+
userId: json.user_id,
|
|
47
|
+
matrixUrl,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function getOpenIdToken(
|
|
52
|
+
matrixAuth: MatrixAuth,
|
|
53
|
+
): Promise<Record<string, unknown>> {
|
|
54
|
+
let response = await fetch(
|
|
55
|
+
new URL(
|
|
56
|
+
`_matrix/client/v3/user/${encodeURIComponent(matrixAuth.userId)}/openid/request_token`,
|
|
57
|
+
matrixAuth.matrixUrl,
|
|
58
|
+
).href,
|
|
59
|
+
{
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: {
|
|
62
|
+
'Content-Type': 'application/json',
|
|
63
|
+
Authorization: `Bearer ${matrixAuth.accessToken}`,
|
|
64
|
+
},
|
|
65
|
+
body: '{}',
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
let text = await response.text();
|
|
71
|
+
throw new Error(`OpenID token request failed: ${response.status} ${text}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (await response.json()) as Record<string, unknown>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function getRealmServerToken(
|
|
78
|
+
matrixAuth: MatrixAuth,
|
|
79
|
+
realmServerUrl: string,
|
|
80
|
+
): Promise<string> {
|
|
81
|
+
let openIdToken = await getOpenIdToken(matrixAuth);
|
|
82
|
+
let url = `${realmServerUrl.replace(/\/$/, '')}/_server-session`;
|
|
83
|
+
|
|
84
|
+
let response = await fetch(url, {
|
|
85
|
+
method: 'POST',
|
|
86
|
+
headers: {
|
|
87
|
+
Accept: 'application/json',
|
|
88
|
+
'Content-Type': 'application/json',
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify(openIdToken),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
let text = await response.text();
|
|
95
|
+
throw new Error(`Realm server session failed: ${response.status} ${text}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let token = response.headers.get('Authorization');
|
|
99
|
+
if (!token) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
'Realm server session response did not include an Authorization header',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return token;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function getRealmTokens(
|
|
108
|
+
realmServerUrl: string,
|
|
109
|
+
serverToken: string,
|
|
110
|
+
): Promise<RealmTokens> {
|
|
111
|
+
let url = `${realmServerUrl.replace(/\/$/, '')}/_realm-auth`;
|
|
112
|
+
|
|
113
|
+
let response = await fetch(url, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: {
|
|
116
|
+
Accept: 'application/json',
|
|
117
|
+
'Content-Type': 'application/json',
|
|
118
|
+
Authorization: serverToken,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
let text = await response.text();
|
|
124
|
+
throw new Error(`Realm auth lookup failed: ${response.status} ${text}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (await response.json()) as RealmTokens;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function addRealmToMatrixAccountData(
|
|
131
|
+
matrixAuth: MatrixAuth,
|
|
132
|
+
realmUrl: string,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
let accountDataUrl = new URL(
|
|
135
|
+
`_matrix/client/v3/user/${encodeURIComponent(matrixAuth.userId)}/account_data/${APP_BOXEL_REALMS_EVENT_TYPE}`,
|
|
136
|
+
matrixAuth.matrixUrl,
|
|
137
|
+
).href;
|
|
138
|
+
|
|
139
|
+
let existingRealms: string[] = [];
|
|
140
|
+
try {
|
|
141
|
+
let getResponse = await fetch(accountDataUrl, {
|
|
142
|
+
headers: { Authorization: `Bearer ${matrixAuth.accessToken}` },
|
|
143
|
+
});
|
|
144
|
+
if (getResponse.ok) {
|
|
145
|
+
let data = (await getResponse.json()) as { realms?: string[] };
|
|
146
|
+
existingRealms = Array.isArray(data.realms) ? [...data.realms] : [];
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// Best-effort — if we can't read existing realms, start fresh
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!existingRealms.includes(realmUrl)) {
|
|
153
|
+
existingRealms.push(realmUrl);
|
|
154
|
+
let putResponse = await fetch(accountDataUrl, {
|
|
155
|
+
method: 'PUT',
|
|
156
|
+
headers: {
|
|
157
|
+
'Content-Type': 'application/json',
|
|
158
|
+
Authorization: `Bearer ${matrixAuth.accessToken}`,
|
|
159
|
+
},
|
|
160
|
+
body: JSON.stringify({ realms: existingRealms }),
|
|
161
|
+
});
|
|
162
|
+
if (!putResponse.ok) {
|
|
163
|
+
let text = await putResponse.text();
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|