@cardstack/boxel-cli 0.0.1 → 0.1.0
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 +31 -24
- 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 +52 -16
- package/src/commands/realm/remove.ts +281 -0
- package/src/commands/realm/sync.ts +153 -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 +60 -2
- package/src/lib/auth-resolver.ts +58 -0
- package/src/lib/auth.ts +56 -12
- package/src/lib/boxel-cli-client.ts +135 -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 +47 -10
- package/src/lib/seed-auth.ts +214 -0
- package/src/lib/watch-lock.ts +81 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Checkpoint } from './checkpoint-manager';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Length of a checkpoint's `shortHash`. SHA-1's first 7 hex chars, set in
|
|
5
|
+
* `CheckpointManager.createCheckpoint`. Used to short-circuit the exact
|
|
6
|
+
* short-hash scan for refs that can't possibly be one.
|
|
7
|
+
*/
|
|
8
|
+
const SHORT_HASH_LENGTH = 7;
|
|
9
|
+
|
|
10
|
+
export type FindResult =
|
|
11
|
+
| { kind: 'found'; target: Checkpoint }
|
|
12
|
+
| { kind: 'none' }
|
|
13
|
+
| { kind: 'ambiguous'; matches: Checkpoint[] };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a `--restore` ref against a list of checkpoints. The ref may be a
|
|
17
|
+
* 1-based index (`'2'`), an exact short hash (`'6701186'`), or a hex prefix
|
|
18
|
+
* of a full hash (`'abc'`).
|
|
19
|
+
*
|
|
20
|
+
* Resolution order:
|
|
21
|
+
* 1. Exact short-hash match. SHA-1 short hashes are all-digits ~5.9% of
|
|
22
|
+
* the time, so digit-only refs that match a short hash exactly must
|
|
23
|
+
* win before the index branch.
|
|
24
|
+
* 2. Digit-only refs → 1-based index. Out-of-range returns `none` rather
|
|
25
|
+
* than falling through to hash-prefix matching, since silently matching
|
|
26
|
+
* a hash whose prefix happens to be digits would surprise users typing
|
|
27
|
+
* what they think is an index.
|
|
28
|
+
* 3. Hex-prefix match against the full hash.
|
|
29
|
+
*/
|
|
30
|
+
export function findCheckpoint(
|
|
31
|
+
ref: string,
|
|
32
|
+
checkpoints: Checkpoint[],
|
|
33
|
+
): FindResult {
|
|
34
|
+
const trimmed = ref.trim();
|
|
35
|
+
// Empty refs would `startsWith('')`-match every hash and silently restore
|
|
36
|
+
// the newest checkpoint — guard explicitly.
|
|
37
|
+
if (trimmed === '') return { kind: 'none' };
|
|
38
|
+
|
|
39
|
+
// Exact short-hash match wins before the digit-only branch.
|
|
40
|
+
if (trimmed.length === SHORT_HASH_LENGTH) {
|
|
41
|
+
const exactShort = checkpoints.filter((cp) => cp.shortHash === trimmed);
|
|
42
|
+
if (exactShort.length === 1) {
|
|
43
|
+
return { kind: 'found', target: exactShort[0] };
|
|
44
|
+
}
|
|
45
|
+
if (exactShort.length > 1) {
|
|
46
|
+
return { kind: 'ambiguous', matches: exactShort };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Digit-only input is an index lookup. Falling through to hash-prefix
|
|
51
|
+
// matching when out of range would silently match short hashes whose prefix
|
|
52
|
+
// happens to be digits.
|
|
53
|
+
if (/^\d+$/.test(trimmed)) {
|
|
54
|
+
const num = parseInt(trimmed, 10);
|
|
55
|
+
if (num >= 1 && num <= checkpoints.length) {
|
|
56
|
+
return { kind: 'found', target: checkpoints[num - 1] };
|
|
57
|
+
}
|
|
58
|
+
return { kind: 'none' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const matches = checkpoints.filter((cp) => cp.hash.startsWith(trimmed));
|
|
62
|
+
if (matches.length === 0) return { kind: 'none' };
|
|
63
|
+
if (matches.length === 1) return { kind: 'found', target: matches[0] };
|
|
64
|
+
return { kind: 'ambiguous', matches };
|
|
65
|
+
}
|
|
@@ -7,12 +7,18 @@ import {
|
|
|
7
7
|
getRealmServerToken as fetchRealmServerToken,
|
|
8
8
|
getRealmTokens,
|
|
9
9
|
addRealmToMatrixAccountData,
|
|
10
|
+
removeRealmFromMatrixAccountData,
|
|
11
|
+
getUserRealmsFromMatrixAccountData,
|
|
10
12
|
type MatrixAuth,
|
|
11
13
|
} from './auth';
|
|
14
|
+
import type { RealmAuthenticator } from './realm-authenticator';
|
|
12
15
|
|
|
13
16
|
const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.boxel-cli');
|
|
14
17
|
const PROFILES_FILENAME = 'profiles.json';
|
|
15
18
|
|
|
19
|
+
export const NO_ACTIVE_PROFILE_ERROR =
|
|
20
|
+
'No active profile. Run `boxel profile add` to create one.';
|
|
21
|
+
|
|
16
22
|
export interface Profile {
|
|
17
23
|
displayName: string;
|
|
18
24
|
matrixUrl: string;
|
|
@@ -77,15 +83,15 @@ export function getEnvironmentLabel(env: Environment): string {
|
|
|
77
83
|
|
|
78
84
|
/**
|
|
79
85
|
* Format profile for display in command output
|
|
80
|
-
* @example [ctse ·
|
|
86
|
+
* @example [ctse · stack.cards]
|
|
81
87
|
*/
|
|
82
88
|
export function formatProfileBadge(matrixId: string): string {
|
|
83
89
|
const username = getUsernameFromMatrixId(matrixId);
|
|
84
|
-
const
|
|
85
|
-
return `${DIM}[${RESET}${FG_CYAN}${username}${RESET} ${DIM}\u00b7${RESET} ${FG_MAGENTA}${
|
|
90
|
+
const domain = getDomainFromMatrixId(matrixId);
|
|
91
|
+
return `${DIM}[${RESET}${FG_CYAN}${username}${RESET} ${DIM}\u00b7${RESET} ${FG_MAGENTA}${domain}${RESET}${DIM}]${RESET}`;
|
|
86
92
|
}
|
|
87
93
|
|
|
88
|
-
export class ProfileManager {
|
|
94
|
+
export class ProfileManager implements RealmAuthenticator {
|
|
89
95
|
private config: ProfilesConfig;
|
|
90
96
|
private configDir: string;
|
|
91
97
|
private profilesFile: string;
|
|
@@ -293,6 +299,35 @@ export class ProfileManager {
|
|
|
293
299
|
return true;
|
|
294
300
|
}
|
|
295
301
|
|
|
302
|
+
// Update one or both server URLs for an existing profile. Cached realm
|
|
303
|
+
// tokens (and the realm-server token) are tied to the previous servers,
|
|
304
|
+
// so they're cleared whenever URLs actually change.
|
|
305
|
+
// Returns true iff at least one URL changed.
|
|
306
|
+
updateUrls(
|
|
307
|
+
profileId: string,
|
|
308
|
+
urls: { matrixUrl?: string; realmServerUrl?: string },
|
|
309
|
+
): boolean {
|
|
310
|
+
const profile = this.config.profiles[profileId];
|
|
311
|
+
if (!profile) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
let changed = false;
|
|
315
|
+
if (urls.matrixUrl && urls.matrixUrl !== profile.matrixUrl) {
|
|
316
|
+
profile.matrixUrl = urls.matrixUrl;
|
|
317
|
+
changed = true;
|
|
318
|
+
}
|
|
319
|
+
if (urls.realmServerUrl && urls.realmServerUrl !== profile.realmServerUrl) {
|
|
320
|
+
profile.realmServerUrl = urls.realmServerUrl;
|
|
321
|
+
changed = true;
|
|
322
|
+
}
|
|
323
|
+
if (changed) {
|
|
324
|
+
profile.realmTokens = undefined;
|
|
325
|
+
profile.realmServerToken = undefined;
|
|
326
|
+
this.saveConfig();
|
|
327
|
+
}
|
|
328
|
+
return changed;
|
|
329
|
+
}
|
|
330
|
+
|
|
296
331
|
setRealmToken(realmUrl: string, token: string): void {
|
|
297
332
|
let active = this.getActiveProfile();
|
|
298
333
|
if (!active) {
|
|
@@ -490,6 +525,16 @@ export class ProfileManager {
|
|
|
490
525
|
await addRealmToMatrixAccountData(matrixAuth, realmUrl);
|
|
491
526
|
}
|
|
492
527
|
|
|
528
|
+
async removeFromUserRealms(realmUrl: string): Promise<boolean> {
|
|
529
|
+
let matrixAuth = await this.loginToMatrix();
|
|
530
|
+
return removeRealmFromMatrixAccountData(matrixAuth, realmUrl);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async getUserRealms(): Promise<string[]> {
|
|
534
|
+
let matrixAuth = await this.loginToMatrix();
|
|
535
|
+
return getUserRealmsFromMatrixAccountData(matrixAuth);
|
|
536
|
+
}
|
|
537
|
+
|
|
493
538
|
async migrateFromEnv(): Promise<{
|
|
494
539
|
profileId: string;
|
|
495
540
|
created: boolean;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import * as readline from 'readline';
|
|
2
|
+
import { Writable } from 'stream';
|
|
3
|
+
|
|
4
|
+
export function prompt(question: string): Promise<string> {
|
|
5
|
+
const rl = readline.createInterface({
|
|
6
|
+
input: process.stdin,
|
|
7
|
+
output: process.stdout,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
rl.question(question, (answer) => {
|
|
12
|
+
rl.close();
|
|
13
|
+
resolve(answer.trim());
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read a secret from stdin without echoing it to the TTY. Keystrokes are
|
|
20
|
+
* masked with `*` and Ctrl+C exits. Intended for seeds, passwords, and other
|
|
21
|
+
* sensitive CLI input that must not appear in shell history or `ps aux`.
|
|
22
|
+
*/
|
|
23
|
+
export function promptPassword(question: string): Promise<string> {
|
|
24
|
+
const mutableOutput = new Writable({
|
|
25
|
+
write: (_chunk, _encoding, callback) => callback(),
|
|
26
|
+
});
|
|
27
|
+
const rl = readline.createInterface({
|
|
28
|
+
input: process.stdin,
|
|
29
|
+
output: mutableOutput,
|
|
30
|
+
terminal: true,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const stdin = process.stdin;
|
|
35
|
+
const wasFlowing = stdin.readableFlowing;
|
|
36
|
+
|
|
37
|
+
if (stdin.isTTY) {
|
|
38
|
+
stdin.setRawMode(true);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const cleanup = () => {
|
|
42
|
+
stdin.removeListener('data', onData);
|
|
43
|
+
if (stdin.isTTY) {
|
|
44
|
+
stdin.setRawMode(false);
|
|
45
|
+
}
|
|
46
|
+
rl.close();
|
|
47
|
+
if (!wasFlowing) {
|
|
48
|
+
stdin.pause();
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const onData = (chunk: Buffer) => {
|
|
53
|
+
try {
|
|
54
|
+
// Pastes arrive as a single data event containing many characters.
|
|
55
|
+
// Strip bracketed-paste markers if the terminal sent them, then walk
|
|
56
|
+
// the chunk one code point at a time so newlines, backspace, and
|
|
57
|
+
// Ctrl+C inside a paste still work.
|
|
58
|
+
const raw = chunk
|
|
59
|
+
.toString()
|
|
60
|
+
.split('[200~')
|
|
61
|
+
.join('')
|
|
62
|
+
.split('[201~')
|
|
63
|
+
.join('');
|
|
64
|
+
for (const c of raw) {
|
|
65
|
+
if (c === '\n' || c === '\r') {
|
|
66
|
+
cleanup();
|
|
67
|
+
process.stdout.write('\n');
|
|
68
|
+
resolve(password);
|
|
69
|
+
return;
|
|
70
|
+
} else if (c === '\u0003') {
|
|
71
|
+
// Ctrl+C
|
|
72
|
+
cleanup();
|
|
73
|
+
process.exit();
|
|
74
|
+
} else if (c === '\u007F' || c === '\b') {
|
|
75
|
+
// Backspace
|
|
76
|
+
if (password.length > 0) {
|
|
77
|
+
password = password.slice(0, -1);
|
|
78
|
+
process.stdout.write('\b \b');
|
|
79
|
+
}
|
|
80
|
+
} else if (c >= ' ') {
|
|
81
|
+
// Printable character; suppress other control bytes entirely.
|
|
82
|
+
password += c;
|
|
83
|
+
process.stdout.write('*');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch (e) {
|
|
87
|
+
cleanup();
|
|
88
|
+
reject(e);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
let password = '';
|
|
93
|
+
try {
|
|
94
|
+
process.stdout.write(question);
|
|
95
|
+
stdin.on('data', onData);
|
|
96
|
+
stdin.resume();
|
|
97
|
+
} catch (e) {
|
|
98
|
+
cleanup();
|
|
99
|
+
reject(e);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve a realm secret seed for administrative CLI operations.
|
|
106
|
+
*
|
|
107
|
+
* Precedence:
|
|
108
|
+
* 1. `BOXEL_REALM_SECRET_SEED` env var — used silently if set.
|
|
109
|
+
* 2. If `flagPresent` is true, prompt the user (no echo).
|
|
110
|
+
* 3. Otherwise return undefined — caller falls back to profile auth.
|
|
111
|
+
*
|
|
112
|
+
* Throws when `--realm-secret-seed` is requested but stdin is not a TTY
|
|
113
|
+
* (e.g. CI, piped shells) — otherwise `promptPassword` would hang
|
|
114
|
+
* indefinitely waiting for keypress input it can never receive.
|
|
115
|
+
*/
|
|
116
|
+
export async function resolveRealmSecretSeed(
|
|
117
|
+
flagPresent: boolean,
|
|
118
|
+
): Promise<string | undefined> {
|
|
119
|
+
const fromEnv = process.env.BOXEL_REALM_SECRET_SEED;
|
|
120
|
+
if (fromEnv) {
|
|
121
|
+
return fromEnv;
|
|
122
|
+
}
|
|
123
|
+
if (!flagPresent) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
if (!process.stdin.isTTY) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
'Cannot prompt for realm secret seed: stdin is not a TTY. ' +
|
|
129
|
+
'Set BOXEL_REALM_SECRET_SEED in the environment instead.',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return promptPassword('Realm secret seed: ');
|
|
133
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow interface over whatever strategy provides authenticated fetch to a
|
|
3
|
+
* realm. Both `ProfileManager` (Matrix login + per-realm JWT) and
|
|
4
|
+
* `SeedAuthenticator` (mint a JWT directly from a shared secret seed) satisfy
|
|
5
|
+
* this interface, so `RealmSyncBase` can accept either.
|
|
6
|
+
*/
|
|
7
|
+
export interface RealmAuthenticator {
|
|
8
|
+
authedRealmFetch(
|
|
9
|
+
input: string | URL | Request,
|
|
10
|
+
init?: RequestInit,
|
|
11
|
+
): Promise<Response>;
|
|
12
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { RealmAuthenticator } from './realm-authenticator';
|
|
2
2
|
import * as fs from 'fs/promises';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import ignoreModule from 'ignore';
|
|
@@ -24,6 +24,22 @@ async function pathExists(p: string): Promise<boolean> {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Decode an `atomic:results` `data.id` (or any href the realm echoes
|
|
29
|
+
* back with URL-encoded path segments). Used so paths that contain
|
|
30
|
+
* spaces or other characters that get percent-encoded on the wire
|
|
31
|
+
* round-trip to the same relative path the local listing uses.
|
|
32
|
+
* Falls back to the raw value on a malformed escape so a single bad
|
|
33
|
+
* entry can't kill the whole sync.
|
|
34
|
+
*/
|
|
35
|
+
function decodeAtomicResultId(id: string): string {
|
|
36
|
+
try {
|
|
37
|
+
return decodeURIComponent(id);
|
|
38
|
+
} catch {
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
export const SupportedMimeType = {
|
|
28
44
|
CardSource: 'application/vnd.card+source',
|
|
29
45
|
DirectoryListing: 'application/vnd.api+json',
|
|
@@ -49,7 +65,7 @@ export abstract class RealmSyncBase {
|
|
|
49
65
|
|
|
50
66
|
constructor(
|
|
51
67
|
protected options: SyncOptions,
|
|
52
|
-
protected
|
|
68
|
+
protected authenticator: RealmAuthenticator,
|
|
53
69
|
) {
|
|
54
70
|
this.normalizedRealmUrl = this.normalizeRealmUrl(options.realmUrl);
|
|
55
71
|
}
|
|
@@ -98,7 +114,7 @@ export abstract class RealmSyncBase {
|
|
|
98
114
|
try {
|
|
99
115
|
const url = this.buildDirectoryUrl(dir);
|
|
100
116
|
|
|
101
|
-
const response = await this.
|
|
117
|
+
const response = await this.authenticator.authedRealmFetch(url, {
|
|
102
118
|
headers: {
|
|
103
119
|
Accept: 'application/vnd.api+json',
|
|
104
120
|
},
|
|
@@ -178,7 +194,7 @@ export abstract class RealmSyncBase {
|
|
|
178
194
|
try {
|
|
179
195
|
const url = `${this.normalizedRealmUrl}_mtimes`;
|
|
180
196
|
|
|
181
|
-
const response = await this.
|
|
197
|
+
const response = await this.authenticator.authedRealmFetch(url, {
|
|
182
198
|
headers: {
|
|
183
199
|
Accept: SupportedMimeType.Mtimes,
|
|
184
200
|
},
|
|
@@ -217,7 +233,22 @@ export abstract class RealmSyncBase {
|
|
|
217
233
|
}
|
|
218
234
|
}
|
|
219
235
|
for (const [fileUrl, mtime] of remoteMtimeEntries) {
|
|
220
|
-
const
|
|
236
|
+
const rawRelativePath = fileUrl.replace(this.normalizedRealmUrl, '');
|
|
237
|
+
// Realm `_mtimes` keys are URL-encoded (e.g. spaces → %20).
|
|
238
|
+
// The local file listing uses decoded paths
|
|
239
|
+
// (`Knowledge Articles/foo.json`), so leaving the remote
|
|
240
|
+
// form encoded makes the diff treat the encoded and decoded
|
|
241
|
+
// variants as two separate files — sync then "downloads"
|
|
242
|
+
// the remote copy alongside the existing local one and
|
|
243
|
+
// duplicates the workspace.
|
|
244
|
+
let relativePath: string;
|
|
245
|
+
try {
|
|
246
|
+
relativePath = decodeURIComponent(rawRelativePath);
|
|
247
|
+
} catch {
|
|
248
|
+
// Malformed percent escape — fall back to the raw value
|
|
249
|
+
// so a single bad entry doesn't kill the whole sync.
|
|
250
|
+
relativePath = rawRelativePath;
|
|
251
|
+
}
|
|
221
252
|
if (!this.shouldIgnoreRemoteFile(relativePath)) {
|
|
222
253
|
mtimes.set(relativePath, mtime);
|
|
223
254
|
}
|
|
@@ -355,7 +386,7 @@ export abstract class RealmSyncBase {
|
|
|
355
386
|
const content = await fs.readFile(localPath, 'utf8');
|
|
356
387
|
const url = this.buildFileUrl(relativePath);
|
|
357
388
|
|
|
358
|
-
const response = await this.
|
|
389
|
+
const response = await this.authenticator.authedRealmFetch(url, {
|
|
359
390
|
method: 'POST',
|
|
360
391
|
headers: {
|
|
361
392
|
'Content-Type': 'text/plain;charset=UTF-8',
|
|
@@ -423,7 +454,7 @@ export abstract class RealmSyncBase {
|
|
|
423
454
|
);
|
|
424
455
|
|
|
425
456
|
const url = `${this.normalizedRealmUrl}_atomic`;
|
|
426
|
-
const response = await this.
|
|
457
|
+
const response = await this.authenticator.authedRealmFetch(url, {
|
|
427
458
|
method: 'POST',
|
|
428
459
|
headers: {
|
|
429
460
|
'Content-Type': 'application/vnd.api+json',
|
|
@@ -439,9 +470,15 @@ export abstract class RealmSyncBase {
|
|
|
439
470
|
const hrefToRelative = new Map(
|
|
440
471
|
entries.map(([rel]) => [this.buildFileUrl(rel), rel]),
|
|
441
472
|
);
|
|
473
|
+
// The realm normalizes hrefs: a path with a space goes out as
|
|
474
|
+
// `Knowledge Articles/...` but comes back URL-encoded as
|
|
475
|
+
// `Knowledge%20Articles/...`. Decode the response id before the
|
|
476
|
+
// map lookup so we resolve back to the original relative path
|
|
477
|
+
// instead of falling through to the raw encoded URL.
|
|
442
478
|
const succeeded = (body['atomic:results'] ?? [])
|
|
443
479
|
.map((r) => r.data?.id)
|
|
444
480
|
.filter((id): id is string => typeof id === 'string')
|
|
481
|
+
.map((id) => decodeAtomicResultId(id))
|
|
445
482
|
.map((id) => hrefToRelative.get(id) ?? id);
|
|
446
483
|
for (const rel of succeeded) {
|
|
447
484
|
console.log(` Uploaded: ${rel}`);
|
|
@@ -461,7 +498,7 @@ export abstract class RealmSyncBase {
|
|
|
461
498
|
const perFile = (errorBody.errors ?? []).map((e) => {
|
|
462
499
|
const detail = e.detail ?? '';
|
|
463
500
|
const match = detail.match(/Resource (\S+) /);
|
|
464
|
-
const href = match ? match[1] : '';
|
|
501
|
+
const href = match ? decodeAtomicResultId(match[1]) : '';
|
|
465
502
|
const relMap = new Map(
|
|
466
503
|
entries.map(([rel]) => [this.buildFileUrl(rel), rel]),
|
|
467
504
|
);
|
|
@@ -495,7 +532,7 @@ export abstract class RealmSyncBase {
|
|
|
495
532
|
|
|
496
533
|
const url = this.buildFileUrl(relativePath);
|
|
497
534
|
|
|
498
|
-
const response = await this.
|
|
535
|
+
const response = await this.authenticator.authedRealmFetch(url, {
|
|
499
536
|
headers: {
|
|
500
537
|
Accept: SupportedMimeType.CardSource,
|
|
501
538
|
},
|
|
@@ -531,7 +568,7 @@ export abstract class RealmSyncBase {
|
|
|
531
568
|
|
|
532
569
|
const url = this.buildFileUrl(relativePath);
|
|
533
570
|
|
|
534
|
-
const response = await this.
|
|
571
|
+
const response = await this.authenticator.authedRealmFetch(url, {
|
|
535
572
|
method: 'DELETE',
|
|
536
573
|
headers: {
|
|
537
574
|
Accept: SupportedMimeType.CardSource,
|
|
@@ -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
|
+
}
|