@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,214 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
import type { RealmAuthenticator } from './realm-authenticator';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The realm server's shared matrix-client username in every deployed
|
|
6
|
+
* environment (local, staging, production). Bot user ids are formed as
|
|
7
|
+
* `@realm_server:<host>` and the realm short-circuits authorization for that
|
|
8
|
+
* id — see packages/runtime-common/realm.ts:2221.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_REALM_BOT_USERNAME = 'realm_server';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Derive the Matrix host portion (`:<host>`) for a bot user id from a realm
|
|
14
|
+
* URL, mirroring `userIdFromUsername` in
|
|
15
|
+
* `packages/runtime-common/matrix-client.ts`:
|
|
16
|
+
* - hostname ending in `.localhost` (and bare `localhost`) collapses to `localhost`
|
|
17
|
+
* - otherwise the last two labels of the hostname are used
|
|
18
|
+
* So:
|
|
19
|
+
* - http://localhost:4201/… → localhost
|
|
20
|
+
* - https://realms-staging.stack.cards/… → stack.cards
|
|
21
|
+
* - https://app.boxel.ai/… → boxel.ai
|
|
22
|
+
*/
|
|
23
|
+
export function deriveHostFromRealmUrl(realmUrl: string): string {
|
|
24
|
+
const { hostname } = new URL(realmUrl);
|
|
25
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
|
|
26
|
+
return 'localhost';
|
|
27
|
+
}
|
|
28
|
+
const labels = hostname.split('.');
|
|
29
|
+
if (labels.length <= 2) {
|
|
30
|
+
return hostname;
|
|
31
|
+
}
|
|
32
|
+
return labels.slice(-2).join('.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function deriveBotUserId(
|
|
36
|
+
realmUrl: string,
|
|
37
|
+
username: string = DEFAULT_REALM_BOT_USERNAME,
|
|
38
|
+
): string {
|
|
39
|
+
return `@${username}:${deriveHostFromRealmUrl(realmUrl)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Origin (with trailing slash) for the realm server hosting a given realm URL.
|
|
44
|
+
* This is what the realm embeds in JWT claims as `realmServerURL`.
|
|
45
|
+
*/
|
|
46
|
+
export function deriveRealmServerUrl(realmUrl: string): string {
|
|
47
|
+
return new URL(realmUrl).origin + '/';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeRealmUrl(realmUrl: string): string {
|
|
51
|
+
try {
|
|
52
|
+
const u = new URL(realmUrl);
|
|
53
|
+
return u.href.replace(/\/+$/, '') + '/';
|
|
54
|
+
} catch {
|
|
55
|
+
throw new Error(`Invalid realm URL: ${realmUrl}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SeedAuthenticatorOptions {
|
|
60
|
+
/** Raw realm secret seed used to sign JWTs (HS256). */
|
|
61
|
+
seed: string;
|
|
62
|
+
/**
|
|
63
|
+
* @internal Override the realm-server's matrix-client username. Real
|
|
64
|
+
* deployments all use `realm_server`; tests against a server with a
|
|
65
|
+
* different username inject their own.
|
|
66
|
+
*/
|
|
67
|
+
botUsername?: string;
|
|
68
|
+
/**
|
|
69
|
+
* @internal Full override for the bot matrix user id (e.g.
|
|
70
|
+
* `@node-test_realm-server:localhost`). Used by integration tests that run
|
|
71
|
+
* against a realm on `127.0.0.1`, where the two-label host-derivation
|
|
72
|
+
* formula is nonsensical.
|
|
73
|
+
*/
|
|
74
|
+
botUserId?: string;
|
|
75
|
+
/** @internal Override the 7-day JWT expiry used by real deployments. */
|
|
76
|
+
expiresIn?: jwt.SignOptions['expiresIn'];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface RealmJwtClaims {
|
|
80
|
+
user: string;
|
|
81
|
+
realm: string;
|
|
82
|
+
sessionRoom: undefined;
|
|
83
|
+
permissions: [];
|
|
84
|
+
realmServerURL: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `RealmAuthenticator` implementation that authenticates via a locally-minted
|
|
89
|
+
* JWT signed with the realm secret seed, bypassing Matrix login and the
|
|
90
|
+
* `/_server-session` + `/_realm-auth` handshake.
|
|
91
|
+
*
|
|
92
|
+
* How it works: the realm short-circuits authorization when the JWT's `user`
|
|
93
|
+
* claim equals the realm's own matrix-client user id
|
|
94
|
+
* (packages/runtime-common/realm.ts:2221). That id is stable per deployment —
|
|
95
|
+
* `@realm_server:<host>` in every real environment. So given the seed, we mint
|
|
96
|
+
* a token with `user = @realm_server:<derived-host>`, `realm = <normalized
|
|
97
|
+
* realm url>`, `realmServerURL = <origin>/`, `permissions = []`, and
|
|
98
|
+
* everything else is ignored by the short-circuit.
|
|
99
|
+
*/
|
|
100
|
+
export class SeedAuthenticator implements RealmAuthenticator {
|
|
101
|
+
readonly #seed: string;
|
|
102
|
+
readonly #botUsername: string;
|
|
103
|
+
readonly #botUserIdOverride: string | undefined;
|
|
104
|
+
readonly #expiresIn: jwt.SignOptions['expiresIn'];
|
|
105
|
+
readonly #tokenCache = new Map<string, string>();
|
|
106
|
+
|
|
107
|
+
constructor(options: SeedAuthenticatorOptions) {
|
|
108
|
+
if (!options.seed) {
|
|
109
|
+
throw new Error('SeedAuthenticator requires a non-empty seed');
|
|
110
|
+
}
|
|
111
|
+
this.#seed = options.seed;
|
|
112
|
+
this.#botUsername = options.botUsername ?? DEFAULT_REALM_BOT_USERNAME;
|
|
113
|
+
this.#botUserIdOverride = options.botUserId;
|
|
114
|
+
this.#expiresIn = options.expiresIn ?? '7d';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the JWT claims for a given realm URL. Exposed for tests that need
|
|
119
|
+
* to inspect payload shape without decoding the signed token.
|
|
120
|
+
*/
|
|
121
|
+
buildClaims(realmUrl: string): RealmJwtClaims {
|
|
122
|
+
const normalizedRealm = normalizeRealmUrl(realmUrl);
|
|
123
|
+
const user =
|
|
124
|
+
this.#botUserIdOverride ??
|
|
125
|
+
deriveBotUserId(normalizedRealm, this.#botUsername);
|
|
126
|
+
return {
|
|
127
|
+
user,
|
|
128
|
+
realm: normalizedRealm,
|
|
129
|
+
sessionRoom: undefined,
|
|
130
|
+
permissions: [],
|
|
131
|
+
realmServerURL: deriveRealmServerUrl(normalizedRealm),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Mint (or return a cached) JWT for the given realm URL.
|
|
137
|
+
*/
|
|
138
|
+
mintTokenForRealm(realmUrl: string): string {
|
|
139
|
+
const claims = this.buildClaims(realmUrl);
|
|
140
|
+
const cached = this.#tokenCache.get(claims.realm);
|
|
141
|
+
if (cached) {
|
|
142
|
+
return cached;
|
|
143
|
+
}
|
|
144
|
+
const token = jwt.sign(claims, this.#seed, {
|
|
145
|
+
expiresIn: this.#expiresIn,
|
|
146
|
+
});
|
|
147
|
+
this.#tokenCache.set(claims.realm, token);
|
|
148
|
+
return token;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Given any URL inside a realm (or the realm root itself), return the realm
|
|
153
|
+
* root URL we'll use to mint the token. We match against the set of realm
|
|
154
|
+
* URLs we've already minted tokens for; the fallback (when nothing is
|
|
155
|
+
* pre-registered) takes the request's origin + first two path segments
|
|
156
|
+
* with a trailing slash, which matches the CLI-visible realm URL
|
|
157
|
+
* convention `https://<host>/<owner>/<realm>/`.
|
|
158
|
+
*/
|
|
159
|
+
#resolveRealmUrl(requestUrl: string): string {
|
|
160
|
+
for (const realmUrl of this.#tokenCache.keys()) {
|
|
161
|
+
if (requestUrl.startsWith(realmUrl)) {
|
|
162
|
+
return realmUrl;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const u = new URL(requestUrl);
|
|
166
|
+
const segments = u.pathname.split('/').filter(Boolean);
|
|
167
|
+
const realmRootPath =
|
|
168
|
+
segments.length > 0 ? `/${segments.slice(0, 2).join('/')}/` : '/';
|
|
169
|
+
return `${u.origin}${realmRootPath}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async authedRealmFetch(
|
|
173
|
+
input: string | URL | Request,
|
|
174
|
+
init?: RequestInit,
|
|
175
|
+
): Promise<Response> {
|
|
176
|
+
const url =
|
|
177
|
+
input instanceof Request
|
|
178
|
+
? input.url
|
|
179
|
+
: input instanceof URL
|
|
180
|
+
? input.href
|
|
181
|
+
: input;
|
|
182
|
+
|
|
183
|
+
const realmUrl = this.#resolveRealmUrl(url);
|
|
184
|
+
const token = this.mintTokenForRealm(realmUrl);
|
|
185
|
+
const headers = this.#buildHeaders(input, init, token);
|
|
186
|
+
return fetch(input, { ...init, headers });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#buildHeaders(
|
|
190
|
+
input: string | URL | Request,
|
|
191
|
+
init: RequestInit | undefined,
|
|
192
|
+
token: string,
|
|
193
|
+
): Headers {
|
|
194
|
+
const baseHeaders =
|
|
195
|
+
input instanceof Request ? new Headers(input.headers) : new Headers();
|
|
196
|
+
const initHeaders = new Headers(init?.headers);
|
|
197
|
+
for (const [key, value] of initHeaders) {
|
|
198
|
+
baseHeaders.set(key, value);
|
|
199
|
+
}
|
|
200
|
+
if (!baseHeaders.has('Authorization')) {
|
|
201
|
+
baseHeaders.set('Authorization', token);
|
|
202
|
+
}
|
|
203
|
+
return baseHeaders;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Pre-register a realm URL so that requests to sub-paths of it always use
|
|
208
|
+
* the exact realm URL for token minting. The CLI commands call this with
|
|
209
|
+
* the user-supplied realm URL before doing any fetches.
|
|
210
|
+
*/
|
|
211
|
+
registerRealmUrl(realmUrl: string): void {
|
|
212
|
+
this.mintTokenForRealm(realmUrl);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
const LOCK_FILE = '.boxel-watch.lock';
|
|
5
|
+
|
|
6
|
+
export interface WatchLockInfo {
|
|
7
|
+
pid: number;
|
|
8
|
+
startedAt: string;
|
|
9
|
+
realmUrl: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type WatchLockResult =
|
|
13
|
+
| { ok: true; staleOverwrote: boolean }
|
|
14
|
+
| { ok: false; existing: WatchLockInfo };
|
|
15
|
+
|
|
16
|
+
function lockPath(localDir: string): string {
|
|
17
|
+
return path.join(localDir, LOCK_FILE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isProcessAlive(pid: number): boolean {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
return true;
|
|
24
|
+
} catch (err: any) {
|
|
25
|
+
// EPERM means the process exists but we can't signal it — still alive.
|
|
26
|
+
return err?.code === 'EPERM';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readLock(localDir: string): Promise<WatchLockInfo | null> {
|
|
31
|
+
try {
|
|
32
|
+
const raw = await fs.readFile(lockPath(localDir), 'utf8');
|
|
33
|
+
const parsed = JSON.parse(raw) as Partial<WatchLockInfo>;
|
|
34
|
+
if (
|
|
35
|
+
typeof parsed.pid !== 'number' ||
|
|
36
|
+
typeof parsed.startedAt !== 'string' ||
|
|
37
|
+
typeof parsed.realmUrl !== 'string'
|
|
38
|
+
) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return parsed as WatchLockInfo;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function acquireWatchLock(
|
|
48
|
+
localDir: string,
|
|
49
|
+
realmUrl: string,
|
|
50
|
+
): Promise<WatchLockResult> {
|
|
51
|
+
await fs.mkdir(localDir, { recursive: true });
|
|
52
|
+
const existing = await readLock(localDir);
|
|
53
|
+
let staleOverwrote = false;
|
|
54
|
+
if (existing && isProcessAlive(existing.pid)) {
|
|
55
|
+
return { ok: false, existing };
|
|
56
|
+
}
|
|
57
|
+
if (existing) {
|
|
58
|
+
staleOverwrote = true;
|
|
59
|
+
}
|
|
60
|
+
const info: WatchLockInfo = {
|
|
61
|
+
pid: process.pid,
|
|
62
|
+
startedAt: new Date().toISOString(),
|
|
63
|
+
realmUrl,
|
|
64
|
+
};
|
|
65
|
+
await fs.writeFile(lockPath(localDir), JSON.stringify(info, null, 2) + '\n');
|
|
66
|
+
return { ok: true, staleOverwrote };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function releaseWatchLock(localDir: string): Promise<void> {
|
|
70
|
+
try {
|
|
71
|
+
await fs.unlink(lockPath(localDir));
|
|
72
|
+
} catch (err: any) {
|
|
73
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function readWatchLock(
|
|
78
|
+
localDir: string,
|
|
79
|
+
): Promise<WatchLockInfo | null> {
|
|
80
|
+
return readLock(localDir);
|
|
81
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2024 Cardstack
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|