@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,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';
|
|
@@ -9,6 +9,8 @@ type Ignore = ReturnType<typeof ignoreModule>;
|
|
|
9
9
|
|
|
10
10
|
// Files that must never be pushed, deleted, or overwritten on the server via CLI.
|
|
11
11
|
export const PROTECTED_FILES = new Set(['.realm.json']);
|
|
12
|
+
const DELETE_TIMEOUT_MS = 10_000;
|
|
13
|
+
const DELETE_TIMEOUT_PROBE_MS = 3_000;
|
|
12
14
|
|
|
13
15
|
export function isProtectedFile(relativePath: string): boolean {
|
|
14
16
|
const normalizedPath = relativePath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
@@ -24,6 +26,22 @@ async function pathExists(p: string): Promise<boolean> {
|
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Decode an `atomic:results` `data.id` (or any href the realm echoes
|
|
31
|
+
* back with URL-encoded path segments). Used so paths that contain
|
|
32
|
+
* spaces or other characters that get percent-encoded on the wire
|
|
33
|
+
* round-trip to the same relative path the local listing uses.
|
|
34
|
+
* Falls back to the raw value on a malformed escape so a single bad
|
|
35
|
+
* entry can't kill the whole sync.
|
|
36
|
+
*/
|
|
37
|
+
function decodeAtomicResultId(id: string): string {
|
|
38
|
+
try {
|
|
39
|
+
return decodeURIComponent(id);
|
|
40
|
+
} catch {
|
|
41
|
+
return id;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
export const SupportedMimeType = {
|
|
28
46
|
CardSource: 'application/vnd.card+source',
|
|
29
47
|
DirectoryListing: 'application/vnd.api+json',
|
|
@@ -34,6 +52,15 @@ export interface SyncOptions {
|
|
|
34
52
|
realmUrl: string;
|
|
35
53
|
localDir: string;
|
|
36
54
|
dryRun?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Append `?waitForIndex=true` to the `_atomic` POST so the realm-server
|
|
57
|
+
* returns only after the indexer has processed the batch. The
|
|
58
|
+
* `_atomic` handler hardcoded `waitForIndex: false` after CS-11003
|
|
59
|
+
* PR 2 (deferred `+source` POST), so callers that read indexed state
|
|
60
|
+
* (search / list) immediately after a sync race the indexer. Off by
|
|
61
|
+
* default.
|
|
62
|
+
*/
|
|
63
|
+
waitForIndex?: boolean;
|
|
37
64
|
}
|
|
38
65
|
|
|
39
66
|
const REMOTE_CONCURRENCY = 10;
|
|
@@ -49,7 +76,7 @@ export abstract class RealmSyncBase {
|
|
|
49
76
|
|
|
50
77
|
constructor(
|
|
51
78
|
protected options: SyncOptions,
|
|
52
|
-
protected
|
|
79
|
+
protected authenticator: RealmAuthenticator,
|
|
53
80
|
) {
|
|
54
81
|
this.normalizedRealmUrl = this.normalizeRealmUrl(options.realmUrl);
|
|
55
82
|
}
|
|
@@ -98,7 +125,7 @@ export abstract class RealmSyncBase {
|
|
|
98
125
|
try {
|
|
99
126
|
const url = this.buildDirectoryUrl(dir);
|
|
100
127
|
|
|
101
|
-
const response = await this.
|
|
128
|
+
const response = await this.authenticator.authedRealmFetch(url, {
|
|
102
129
|
headers: {
|
|
103
130
|
Accept: 'application/vnd.api+json',
|
|
104
131
|
},
|
|
@@ -178,7 +205,7 @@ export abstract class RealmSyncBase {
|
|
|
178
205
|
try {
|
|
179
206
|
const url = `${this.normalizedRealmUrl}_mtimes`;
|
|
180
207
|
|
|
181
|
-
const response = await this.
|
|
208
|
+
const response = await this.authenticator.authedRealmFetch(url, {
|
|
182
209
|
headers: {
|
|
183
210
|
Accept: SupportedMimeType.Mtimes,
|
|
184
211
|
},
|
|
@@ -217,7 +244,22 @@ export abstract class RealmSyncBase {
|
|
|
217
244
|
}
|
|
218
245
|
}
|
|
219
246
|
for (const [fileUrl, mtime] of remoteMtimeEntries) {
|
|
220
|
-
const
|
|
247
|
+
const rawRelativePath = fileUrl.replace(this.normalizedRealmUrl, '');
|
|
248
|
+
// Realm `_mtimes` keys are URL-encoded (e.g. spaces → %20).
|
|
249
|
+
// The local file listing uses decoded paths
|
|
250
|
+
// (`Knowledge Articles/foo.json`), so leaving the remote
|
|
251
|
+
// form encoded makes the diff treat the encoded and decoded
|
|
252
|
+
// variants as two separate files — sync then "downloads"
|
|
253
|
+
// the remote copy alongside the existing local one and
|
|
254
|
+
// duplicates the workspace.
|
|
255
|
+
let relativePath: string;
|
|
256
|
+
try {
|
|
257
|
+
relativePath = decodeURIComponent(rawRelativePath);
|
|
258
|
+
} catch {
|
|
259
|
+
// Malformed percent escape — fall back to the raw value
|
|
260
|
+
// so a single bad entry doesn't kill the whole sync.
|
|
261
|
+
relativePath = rawRelativePath;
|
|
262
|
+
}
|
|
221
263
|
if (!this.shouldIgnoreRemoteFile(relativePath)) {
|
|
222
264
|
mtimes.set(relativePath, mtime);
|
|
223
265
|
}
|
|
@@ -355,7 +397,7 @@ export abstract class RealmSyncBase {
|
|
|
355
397
|
const content = await fs.readFile(localPath, 'utf8');
|
|
356
398
|
const url = this.buildFileUrl(relativePath);
|
|
357
399
|
|
|
358
|
-
const response = await this.
|
|
400
|
+
const response = await this.authenticator.authedRealmFetch(url, {
|
|
359
401
|
method: 'POST',
|
|
360
402
|
headers: {
|
|
361
403
|
'Content-Type': 'text/plain;charset=UTF-8',
|
|
@@ -422,8 +464,10 @@ export abstract class RealmSyncBase {
|
|
|
422
464
|
}),
|
|
423
465
|
);
|
|
424
466
|
|
|
425
|
-
const url =
|
|
426
|
-
|
|
467
|
+
const url = this.options.waitForIndex
|
|
468
|
+
? `${this.normalizedRealmUrl}_atomic?waitForIndex=true`
|
|
469
|
+
: `${this.normalizedRealmUrl}_atomic`;
|
|
470
|
+
const response = await this.authenticator.authedRealmFetch(url, {
|
|
427
471
|
method: 'POST',
|
|
428
472
|
headers: {
|
|
429
473
|
'Content-Type': 'application/vnd.api+json',
|
|
@@ -439,9 +483,15 @@ export abstract class RealmSyncBase {
|
|
|
439
483
|
const hrefToRelative = new Map(
|
|
440
484
|
entries.map(([rel]) => [this.buildFileUrl(rel), rel]),
|
|
441
485
|
);
|
|
486
|
+
// The realm normalizes hrefs: a path with a space goes out as
|
|
487
|
+
// `Knowledge Articles/...` but comes back URL-encoded as
|
|
488
|
+
// `Knowledge%20Articles/...`. Decode the response id before the
|
|
489
|
+
// map lookup so we resolve back to the original relative path
|
|
490
|
+
// instead of falling through to the raw encoded URL.
|
|
442
491
|
const succeeded = (body['atomic:results'] ?? [])
|
|
443
492
|
.map((r) => r.data?.id)
|
|
444
493
|
.filter((id): id is string => typeof id === 'string')
|
|
494
|
+
.map((id) => decodeAtomicResultId(id))
|
|
445
495
|
.map((id) => hrefToRelative.get(id) ?? id);
|
|
446
496
|
for (const rel of succeeded) {
|
|
447
497
|
console.log(` Uploaded: ${rel}`);
|
|
@@ -461,7 +511,7 @@ export abstract class RealmSyncBase {
|
|
|
461
511
|
const perFile = (errorBody.errors ?? []).map((e) => {
|
|
462
512
|
const detail = e.detail ?? '';
|
|
463
513
|
const match = detail.match(/Resource (\S+) /);
|
|
464
|
-
const href = match ? match[1] : '';
|
|
514
|
+
const href = match ? decodeAtomicResultId(match[1]) : '';
|
|
465
515
|
const relMap = new Map(
|
|
466
516
|
entries.map(([rel]) => [this.buildFileUrl(rel), rel]),
|
|
467
517
|
);
|
|
@@ -495,7 +545,7 @@ export abstract class RealmSyncBase {
|
|
|
495
545
|
|
|
496
546
|
const url = this.buildFileUrl(relativePath);
|
|
497
547
|
|
|
498
|
-
const response = await this.
|
|
548
|
+
const response = await this.authenticator.authedRealmFetch(url, {
|
|
499
549
|
headers: {
|
|
500
550
|
Accept: SupportedMimeType.CardSource,
|
|
501
551
|
},
|
|
@@ -530,13 +580,45 @@ export abstract class RealmSyncBase {
|
|
|
530
580
|
}
|
|
531
581
|
|
|
532
582
|
const url = this.buildFileUrl(relativePath);
|
|
583
|
+
const startedAt = Date.now();
|
|
533
584
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
585
|
+
let response: Response;
|
|
586
|
+
try {
|
|
587
|
+
response = await this.authenticator.authedRealmFetch(url, {
|
|
588
|
+
method: 'DELETE',
|
|
589
|
+
headers: {
|
|
590
|
+
Accept: SupportedMimeType.CardSource,
|
|
591
|
+
},
|
|
592
|
+
signal: AbortSignal.timeout(DELETE_TIMEOUT_MS),
|
|
593
|
+
});
|
|
594
|
+
} catch (error) {
|
|
595
|
+
let elapsedMs = Date.now() - startedAt;
|
|
596
|
+
console.error(
|
|
597
|
+
` Delete request failed after ${elapsedMs}ms: ${relativePath}`,
|
|
598
|
+
);
|
|
599
|
+
if (
|
|
600
|
+
error instanceof Error &&
|
|
601
|
+
(error.name === 'TimeoutError' || error.name === 'AbortError')
|
|
602
|
+
) {
|
|
603
|
+
let deleteApplied = await this.verifyDeleteApplied(relativePath);
|
|
604
|
+
if (deleteApplied === true) {
|
|
605
|
+
console.warn(
|
|
606
|
+
` Delete response timed out after ${DELETE_TIMEOUT_MS}ms, but ${relativePath} is already gone on the realm; continuing`,
|
|
607
|
+
);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
throw new Error(
|
|
611
|
+
`Timed out deleting ${relativePath} after ${DELETE_TIMEOUT_MS}ms`,
|
|
612
|
+
{ cause: error },
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
let elapsedMs = Date.now() - startedAt;
|
|
619
|
+
console.log(
|
|
620
|
+
` Delete response for ${relativePath}: ${response.status} ${response.statusText} (${elapsedMs}ms)`,
|
|
621
|
+
);
|
|
540
622
|
|
|
541
623
|
if (!response.ok && response.status !== 404) {
|
|
542
624
|
throw new Error(
|
|
@@ -547,6 +629,30 @@ export abstract class RealmSyncBase {
|
|
|
547
629
|
console.log(` Deleted: ${relativePath}`);
|
|
548
630
|
}
|
|
549
631
|
|
|
632
|
+
private async verifyDeleteApplied(
|
|
633
|
+
relativePath: string,
|
|
634
|
+
): Promise<boolean | 'unknown'> {
|
|
635
|
+
const url = this.buildFileUrl(relativePath);
|
|
636
|
+
try {
|
|
637
|
+
const response = await this.authenticator.authedRealmFetch(url, {
|
|
638
|
+
headers: {
|
|
639
|
+
Accept: SupportedMimeType.CardSource,
|
|
640
|
+
},
|
|
641
|
+
signal: AbortSignal.timeout(DELETE_TIMEOUT_PROBE_MS),
|
|
642
|
+
});
|
|
643
|
+
console.warn(
|
|
644
|
+
` Delete-timeout probe for ${relativePath}: ${response.status} ${response.statusText}`,
|
|
645
|
+
);
|
|
646
|
+
return response.status === 404 ? true : false;
|
|
647
|
+
} catch (probeError) {
|
|
648
|
+
console.warn(
|
|
649
|
+
` Delete-timeout probe failed for ${relativePath}:`,
|
|
650
|
+
probeError,
|
|
651
|
+
);
|
|
652
|
+
return 'unknown';
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
550
656
|
protected async deleteLocalFile(localPath: string): Promise<void> {
|
|
551
657
|
console.log(`Deleting local: ${localPath}`);
|
|
552
658
|
|