@cardstack/boxel-cli 0.0.1 → 0.1.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 +124 -0
- package/api.ts +3 -0
- package/bin/boxel.js +15 -0
- package/dist/index.js +107 -66
- package/package.json +35 -26
- package/src/build-program.ts +91 -0
- package/src/commands/file/delete.ts +110 -0
- package/src/commands/file/index.ts +20 -0
- package/src/commands/file/lint.ts +235 -0
- package/src/commands/file/list.ts +121 -0
- package/src/commands/file/read.ts +113 -0
- package/src/commands/file/touch.ts +222 -0
- package/src/commands/file/write.ts +152 -0
- package/src/commands/profile.ts +199 -106
- package/src/commands/read-transpiled.ts +120 -0
- package/src/commands/realm/cancel-indexing.ts +113 -0
- package/src/commands/realm/create.ts +1 -4
- package/src/commands/realm/history.ts +388 -0
- package/src/commands/realm/index.ts +12 -0
- package/src/commands/realm/list.ts +156 -0
- package/src/commands/realm/pull.ts +51 -17
- package/src/commands/realm/push.ts +79 -27
- package/src/commands/realm/remove.ts +281 -0
- package/src/commands/realm/sync.ts +160 -60
- package/src/commands/realm/wait-for-ready.ts +120 -0
- package/src/commands/realm/watch.ts +626 -0
- package/src/commands/run-command.ts +4 -3
- package/src/commands/search.ts +160 -0
- package/src/index.ts +16 -38
- package/src/lib/auth-resolver.ts +58 -0
- package/src/lib/auth.ts +56 -12
- package/src/lib/boxel-cli-client.ts +146 -279
- package/src/lib/cli-log.ts +132 -0
- package/src/lib/colors.ts +14 -9
- package/src/lib/find-checkpoint.ts +65 -0
- package/src/lib/profile-manager.ts +49 -4
- package/src/lib/prompt.ts +133 -0
- package/src/lib/realm-authenticator.ts +12 -0
- package/src/lib/realm-sync-base.ts +122 -16
- package/src/lib/seed-auth.ts +214 -0
- package/src/lib/watch-lock.ts +81 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import {
|
|
3
|
+
getProfileManager,
|
|
4
|
+
NO_ACTIVE_PROFILE_ERROR,
|
|
5
|
+
type ProfileManager,
|
|
6
|
+
} from '../lib/profile-manager';
|
|
7
|
+
import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
|
|
8
|
+
import { FG_RED, DIM, RESET } from '../lib/colors';
|
|
9
|
+
import { cliLog } from '../lib/cli-log';
|
|
10
|
+
|
|
11
|
+
export interface SearchResult {
|
|
12
|
+
ok: boolean;
|
|
13
|
+
status?: number;
|
|
14
|
+
data?: Record<string, unknown>[];
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SearchCommandOptions {
|
|
19
|
+
profileManager?: ProfileManager;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Federated search across one or more realms via the `_federated-search`
|
|
24
|
+
* server endpoint.
|
|
25
|
+
*
|
|
26
|
+
* Sends a QUERY request with the provided query object and a `realms` array
|
|
27
|
+
* merged into the request body. Uses the server JWT via
|
|
28
|
+
* `ProfileManager.authedRealmServerFetch`.
|
|
29
|
+
*/
|
|
30
|
+
export async function search(
|
|
31
|
+
realmUrls: string | string[],
|
|
32
|
+
query: Record<string, unknown>,
|
|
33
|
+
options?: SearchCommandOptions,
|
|
34
|
+
): Promise<SearchResult> {
|
|
35
|
+
let pm = options?.profileManager ?? getProfileManager();
|
|
36
|
+
let active = pm.getActiveProfile();
|
|
37
|
+
if (!active) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
error: NO_ACTIVE_PROFILE_ERROR,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, '');
|
|
45
|
+
let searchUrl = `${realmServerUrl}/_federated-search`;
|
|
46
|
+
|
|
47
|
+
let realms = (Array.isArray(realmUrls) ? realmUrls : [realmUrls]).map(
|
|
48
|
+
ensureTrailingSlash,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
let response = await pm.authedRealmServerFetch(searchUrl, {
|
|
53
|
+
method: 'QUERY',
|
|
54
|
+
headers: {
|
|
55
|
+
Accept: 'application/vnd.card+json',
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify({ realms, ...query }),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
let body = await response.text();
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
status: response.status,
|
|
66
|
+
error: `HTTP ${response.status}: ${body.slice(0, 300)}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let result = (await response.json()) as {
|
|
71
|
+
data?: Record<string, unknown>[];
|
|
72
|
+
};
|
|
73
|
+
return { ok: true, status: response.status, data: result.data };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
status: 0,
|
|
78
|
+
error: err instanceof Error ? err.message : String(err),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface SearchCliOptions {
|
|
84
|
+
realm: string[];
|
|
85
|
+
query: string;
|
|
86
|
+
json?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function registerSearchCommand(program: Command): void {
|
|
90
|
+
program
|
|
91
|
+
.command('search')
|
|
92
|
+
.description('Federated search across realms using a JSON query')
|
|
93
|
+
.requiredOption(
|
|
94
|
+
'--realm <realm-url>',
|
|
95
|
+
'Realm URL to search (repeatable)',
|
|
96
|
+
(val: string, acc: string[]) => {
|
|
97
|
+
acc.push(val);
|
|
98
|
+
return acc;
|
|
99
|
+
},
|
|
100
|
+
[] as string[],
|
|
101
|
+
)
|
|
102
|
+
.requiredOption('--query <json>', 'JSON query object (as a string)')
|
|
103
|
+
.option('--json', 'Output raw JSON response')
|
|
104
|
+
.action(async (opts: SearchCliOptions) => {
|
|
105
|
+
if (opts.realm.length === 0) {
|
|
106
|
+
console.error(
|
|
107
|
+
`${FG_RED}Error:${RESET} At least one --realm is required`,
|
|
108
|
+
);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let query: Record<string, unknown>;
|
|
113
|
+
try {
|
|
114
|
+
let parsed = JSON.parse(opts.query);
|
|
115
|
+
if (
|
|
116
|
+
typeof parsed !== 'object' ||
|
|
117
|
+
parsed === null ||
|
|
118
|
+
Array.isArray(parsed)
|
|
119
|
+
) {
|
|
120
|
+
console.error(
|
|
121
|
+
`${FG_RED}Error:${RESET} --query must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`,
|
|
122
|
+
);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
query = parsed as Record<string, unknown>;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error(
|
|
128
|
+
`${FG_RED}Error:${RESET} Invalid JSON in --query: ${err instanceof Error ? err.message : String(err)}`,
|
|
129
|
+
);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
return; // unreachable, but helps TS
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let result: SearchResult;
|
|
135
|
+
try {
|
|
136
|
+
result = await search(opts.realm, query);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error(
|
|
139
|
+
`${FG_RED}Error:${RESET} ${err instanceof Error ? err.message : String(err)}`,
|
|
140
|
+
);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (opts.json) {
|
|
146
|
+
cliLog.output(JSON.stringify(result, null, 2));
|
|
147
|
+
} else if (result.ok) {
|
|
148
|
+
cliLog.output(JSON.stringify(result.data ?? [], null, 2));
|
|
149
|
+
} else {
|
|
150
|
+
console.error(
|
|
151
|
+
`${DIM}Status:${RESET} ${result.status ?? '(no status)'}`,
|
|
152
|
+
);
|
|
153
|
+
console.error(`${FG_RED}Error:${RESET} ${result.error}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!result.ok) {
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,47 +1,25 @@
|
|
|
1
1
|
import 'dotenv/config';
|
|
2
|
-
import { Command } from 'commander';
|
|
3
2
|
import { readFileSync } from 'fs';
|
|
4
3
|
import { resolve } from 'path';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { registerRunCommand } from './commands/run-command';
|
|
4
|
+
import { buildBoxelProgram } from './build-program';
|
|
5
|
+
import { setQuiet } from './lib/cli-log';
|
|
8
6
|
|
|
9
7
|
const pkg = JSON.parse(
|
|
10
8
|
readFileSync(resolve(__dirname, '../package.json'), 'utf-8'),
|
|
11
9
|
);
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
// `--quiet` is implemented by intercepting `console.log/info/debug`.
|
|
12
|
+
// New commands: write decorative output (status, confirmations, colored
|
|
13
|
+
// lines) with `console.log` — it's silenced for free under `--quiet`.
|
|
14
|
+
// For programmatic output (`--json` payloads, raw file bytes), use
|
|
15
|
+
// `cliLog.output(...)`. Full guidance: see `lib/cli-log.ts`.
|
|
16
|
+
//
|
|
17
|
+
// Belt-and-suspenders: also flip quiet mode based on a raw scan of argv,
|
|
18
|
+
// so any code that runs between Commander's option parsing and the
|
|
19
|
+
// `preAction` hook sees the right state. We scan for the long form only;
|
|
20
|
+
// `-q` could legitimately be the value of another option in the future.
|
|
21
|
+
if (process.argv.includes('--quiet')) {
|
|
22
|
+
setQuiet(true);
|
|
23
|
+
}
|
|
14
24
|
|
|
15
|
-
|
|
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();
|
|
25
|
+
buildBoxelProgram(pkg.version).parse();
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getProfileManager,
|
|
3
|
+
type ProfileManager,
|
|
4
|
+
NO_ACTIVE_PROFILE_ERROR,
|
|
5
|
+
} from './profile-manager';
|
|
6
|
+
import type { RealmAuthenticator } from './realm-authenticator';
|
|
7
|
+
import { SeedAuthenticator } from './seed-auth';
|
|
8
|
+
|
|
9
|
+
export interface AuthResolverOptions {
|
|
10
|
+
/** Realm URL the command is operating on (used for registering the seed-auth cache). */
|
|
11
|
+
realmUrl: string;
|
|
12
|
+
/**
|
|
13
|
+
* Already-resolved realm secret seed. Callers who want env + prompt
|
|
14
|
+
* resolution should go through `resolveRealmSecretSeed` in `./prompt` first.
|
|
15
|
+
*/
|
|
16
|
+
realmSecretSeed?: string;
|
|
17
|
+
/** Override the ProfileManager (tests). When seed mode is active we won't touch it. */
|
|
18
|
+
profileManager?: ProfileManager;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type AuthResolution =
|
|
22
|
+
| { ok: true; authenticator: RealmAuthenticator; mode: 'seed' | 'profile' }
|
|
23
|
+
| { ok: false; error: string };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pick between seed-based auth and profile-based auth.
|
|
27
|
+
*
|
|
28
|
+
* - If `realmSecretSeed` is present, use `SeedAuthenticator`. We do NOT
|
|
29
|
+
* require a profile in this mode — operators using the seed typically
|
|
30
|
+
* don't have a Matrix account configured.
|
|
31
|
+
* - Otherwise, fall back to the profile flow and require an active profile.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveRealmAuthenticator(
|
|
34
|
+
options: AuthResolverOptions,
|
|
35
|
+
): AuthResolution {
|
|
36
|
+
if (options.realmSecretSeed) {
|
|
37
|
+
// registerRealmUrl throws on a malformed realm URL; surface that as a
|
|
38
|
+
// resolver error so pull/push/sync keep their friendly CLI error path.
|
|
39
|
+
try {
|
|
40
|
+
const authenticator = new SeedAuthenticator({
|
|
41
|
+
seed: options.realmSecretSeed,
|
|
42
|
+
});
|
|
43
|
+
authenticator.registerRealmUrl(options.realmUrl);
|
|
44
|
+
return { ok: true, authenticator, mode: 'seed' };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
error: error instanceof Error ? error.message : String(error),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const pm = options.profileManager ?? getProfileManager();
|
|
54
|
+
if (!pm.getActiveProfile()) {
|
|
55
|
+
return { ok: false, error: NO_ACTIVE_PROFILE_ERROR };
|
|
56
|
+
}
|
|
57
|
+
return { ok: true, authenticator: pm, mode: 'profile' };
|
|
58
|
+
}
|
package/src/lib/auth.ts
CHANGED
|
@@ -14,6 +14,7 @@ interface MatrixLoginResponse {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
import { APP_BOXEL_REALMS_EVENT_TYPE } from '@cardstack/runtime-common/matrix-constants';
|
|
17
|
+
import { ensureTrailingSlash } from '@cardstack/runtime-common/paths';
|
|
17
18
|
|
|
18
19
|
export async function matrixLogin(
|
|
19
20
|
matrixUrl: string,
|
|
@@ -127,31 +128,40 @@ export async function getRealmTokens(
|
|
|
127
128
|
return (await response.json()) as RealmTokens;
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
realmUrl: string,
|
|
133
|
-
): Promise<void> {
|
|
134
|
-
let accountDataUrl = new URL(
|
|
131
|
+
function userRealmsAccountDataUrl(matrixAuth: MatrixAuth): string {
|
|
132
|
+
return new URL(
|
|
135
133
|
`_matrix/client/v3/user/${encodeURIComponent(matrixAuth.userId)}/account_data/${APP_BOXEL_REALMS_EVENT_TYPE}`,
|
|
136
134
|
matrixAuth.matrixUrl,
|
|
137
135
|
).href;
|
|
136
|
+
}
|
|
138
137
|
|
|
139
|
-
|
|
138
|
+
export async function getUserRealmsFromMatrixAccountData(
|
|
139
|
+
matrixAuth: MatrixAuth,
|
|
140
|
+
): Promise<string[]> {
|
|
140
141
|
try {
|
|
141
|
-
let
|
|
142
|
+
let response = await fetch(userRealmsAccountDataUrl(matrixAuth), {
|
|
142
143
|
headers: { Authorization: `Bearer ${matrixAuth.accessToken}` },
|
|
143
144
|
});
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
existingRealms = Array.isArray(data.realms) ? [...data.realms] : [];
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
return [];
|
|
147
147
|
}
|
|
148
|
+
let data = (await response.json()) as { realms?: string[] };
|
|
149
|
+
return Array.isArray(data.realms) ? [...data.realms] : [];
|
|
148
150
|
} catch {
|
|
149
|
-
// Best-effort —
|
|
151
|
+
// Best-effort — treat unreachable account data as an empty list
|
|
152
|
+
return [];
|
|
150
153
|
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function addRealmToMatrixAccountData(
|
|
157
|
+
matrixAuth: MatrixAuth,
|
|
158
|
+
realmUrl: string,
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
let existingRealms = await getUserRealmsFromMatrixAccountData(matrixAuth);
|
|
151
161
|
|
|
152
162
|
if (!existingRealms.includes(realmUrl)) {
|
|
153
163
|
existingRealms.push(realmUrl);
|
|
154
|
-
let putResponse = await fetch(
|
|
164
|
+
let putResponse = await fetch(userRealmsAccountDataUrl(matrixAuth), {
|
|
155
165
|
method: 'PUT',
|
|
156
166
|
headers: {
|
|
157
167
|
'Content-Type': 'application/json',
|
|
@@ -167,3 +177,37 @@ export async function addRealmToMatrixAccountData(
|
|
|
167
177
|
}
|
|
168
178
|
}
|
|
169
179
|
}
|
|
180
|
+
|
|
181
|
+
// Returns true when at least one entry was removed and a write occurred,
|
|
182
|
+
// false when no entry matched the URL (caller decides how to surface that
|
|
183
|
+
// to the user). Comparison is normalized via `ensureTrailingSlash` and every
|
|
184
|
+
// matching entry is dropped, so legacy duplicates like `https://host/realm`
|
|
185
|
+
// + `https://host/realm/` are both cleaned out in a single PUT.
|
|
186
|
+
export async function removeRealmFromMatrixAccountData(
|
|
187
|
+
matrixAuth: MatrixAuth,
|
|
188
|
+
realmUrl: string,
|
|
189
|
+
): Promise<boolean> {
|
|
190
|
+
let target = ensureTrailingSlash(realmUrl);
|
|
191
|
+
let existingRealms = await getUserRealmsFromMatrixAccountData(matrixAuth);
|
|
192
|
+
let next = existingRealms.filter(
|
|
193
|
+
(url) => ensureTrailingSlash(url) !== target,
|
|
194
|
+
);
|
|
195
|
+
if (next.length === existingRealms.length) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
let putResponse = await fetch(userRealmsAccountDataUrl(matrixAuth), {
|
|
199
|
+
method: 'PUT',
|
|
200
|
+
headers: {
|
|
201
|
+
'Content-Type': 'application/json',
|
|
202
|
+
Authorization: `Bearer ${matrixAuth.accessToken}`,
|
|
203
|
+
},
|
|
204
|
+
body: JSON.stringify({ realms: next }),
|
|
205
|
+
});
|
|
206
|
+
if (!putResponse.ok) {
|
|
207
|
+
let text = await putResponse.text();
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Failed to update Matrix account data: ${putResponse.status} ${text}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
}
|