@openstory/cli 0.0.0-stage → 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 +37 -2
- package/bin/openstory.mjs +4 -0
- package/dist/api.d.ts +19 -0
- package/dist/api.js +69 -0
- package/dist/assets.d.ts +27 -0
- package/dist/assets.js +283 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +109 -0
- package/dist/context.d.ts +23 -0
- package/dist/context.js +22 -0
- package/dist/login.d.ts +17 -0
- package/dist/login.js +148 -0
- package/dist/pull.d.ts +11 -0
- package/dist/pull.js +63 -0
- package/dist/share.d.ts +21 -0
- package/dist/share.js +152 -0
- package/dist/sync.d.ts +27 -0
- package/dist/sync.js +355 -0
- package/dist/update-notice.d.ts +13 -0
- package/dist/update-notice.js +55 -0
- package/package.json +35 -4
package/dist/login.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing in to OpenStory with the public API's device code, and the keys it
|
|
3
|
+
* leaves in ~/.openstory/credentials.json, one per origin.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { errorText, recordOf, stringOf } from './api.js';
|
|
8
|
+
import { parseOrigin } from './context.js';
|
|
9
|
+
const DIR = '.openstory';
|
|
10
|
+
const KEY_FILE = 'credentials.json';
|
|
11
|
+
/** Where stitch kept the keys before 0.5.0. */
|
|
12
|
+
const OLD_KEY_FILE = 'stitch.json';
|
|
13
|
+
const DEVICE_WAIT = '60s';
|
|
14
|
+
export const login = {
|
|
15
|
+
usage: 'openstory login [--origin url]',
|
|
16
|
+
async run(args, context) {
|
|
17
|
+
const { origin, rest } = parseOrigin(args);
|
|
18
|
+
if (rest[0] !== undefined)
|
|
19
|
+
throw new Error(`Unknown argument: ${rest[0]}`);
|
|
20
|
+
await signIn({ ...context, origin });
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
/** Renames stitch's key file once; never over an existing credentials file. */
|
|
24
|
+
export async function moveOldCredentials(home) {
|
|
25
|
+
const dir = join(home, DIR);
|
|
26
|
+
if (await exists(join(dir, KEY_FILE)))
|
|
27
|
+
return;
|
|
28
|
+
if (!(await exists(join(dir, OLD_KEY_FILE))))
|
|
29
|
+
return;
|
|
30
|
+
await rename(join(dir, OLD_KEY_FILE), join(dir, KEY_FILE));
|
|
31
|
+
}
|
|
32
|
+
/** The stored key, or a fresh device-code sign-in. */
|
|
33
|
+
export async function openStoryApiKey(options) {
|
|
34
|
+
return (await storedKey(options.home, options.origin)) ?? signIn(options);
|
|
35
|
+
}
|
|
36
|
+
export async function storedKey(home, origin) {
|
|
37
|
+
return (await readKeyFile(home)).keys[origin] ?? null;
|
|
38
|
+
}
|
|
39
|
+
async function readKeyFile(home) {
|
|
40
|
+
let text;
|
|
41
|
+
try {
|
|
42
|
+
text = await readFile(join(home, DIR, KEY_FILE), 'utf8');
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
// Only a missing file means "no keys yet". Anything else would be
|
|
46
|
+
// overwritten by the next sign-in, taking every other origin's key with it.
|
|
47
|
+
if (isMissingFile(error))
|
|
48
|
+
return { keys: {} };
|
|
49
|
+
throw new Error(`~/${DIR}/${KEY_FILE} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
50
|
+
}
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(text);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new Error(`~/${DIR}/${KEY_FILE} is not JSON; fix or remove it`);
|
|
57
|
+
}
|
|
58
|
+
const keys = recordOf(recordOf(parsed).keys);
|
|
59
|
+
return {
|
|
60
|
+
keys: Object.fromEntries(Object.entries(keys).filter((entry) => typeof entry[1] === 'string')),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function exists(file) {
|
|
64
|
+
try {
|
|
65
|
+
await stat(file);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (isMissingFile(error))
|
|
70
|
+
return false;
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function isMissingFile(error) {
|
|
75
|
+
return (typeof error === 'object' &&
|
|
76
|
+
error !== null &&
|
|
77
|
+
'code' in error &&
|
|
78
|
+
error.code === 'ENOENT');
|
|
79
|
+
}
|
|
80
|
+
async function storeKey(home, origin, key) {
|
|
81
|
+
const file = await readKeyFile(home);
|
|
82
|
+
file.keys[origin] = key;
|
|
83
|
+
const dir = join(home, DIR);
|
|
84
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
85
|
+
await writeFile(join(dir, KEY_FILE), JSON.stringify(file, null, 2), {
|
|
86
|
+
mode: 0o600,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The sign-in page must be on the server we are talking to: the string goes
|
|
91
|
+
* to the OS launcher, so no other origin or scheme is passed through.
|
|
92
|
+
*/
|
|
93
|
+
function sameOriginUrl(value, origin) {
|
|
94
|
+
let url;
|
|
95
|
+
try {
|
|
96
|
+
url = new URL(value);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
throw new Error('OpenStory answered with an unreadable sign-in page');
|
|
100
|
+
}
|
|
101
|
+
if (url.origin !== origin) {
|
|
102
|
+
throw new Error(`The sign-in page is not on ${origin}`);
|
|
103
|
+
}
|
|
104
|
+
return url.href;
|
|
105
|
+
}
|
|
106
|
+
const sleep = (seconds) => new Promise((r) => setTimeout(r, seconds * 1000));
|
|
107
|
+
/** Device-code sign-in (RFC 8628 shape); stores and returns the API key. */
|
|
108
|
+
export async function signIn(options) {
|
|
109
|
+
const { origin, fetch: fetchFn } = options;
|
|
110
|
+
const started = await fetchFn(new URL('/api/v1/device/code', origin), {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
});
|
|
113
|
+
if (!started.ok) {
|
|
114
|
+
throw new Error(`OpenStory could not start a sign-in (${started.status}) ${await errorText(started)}`);
|
|
115
|
+
}
|
|
116
|
+
const answer = recordOf(await started.json());
|
|
117
|
+
const code = {
|
|
118
|
+
deviceCode: stringOf(answer.device_code, 'a device code'),
|
|
119
|
+
userCode: stringOf(answer.user_code, 'a user code'),
|
|
120
|
+
verifyUrl: sameOriginUrl(stringOf(answer.verification_url_complete, 'a sign-in page'), origin),
|
|
121
|
+
interval: typeof answer.interval === 'number' ? answer.interval : 5,
|
|
122
|
+
};
|
|
123
|
+
options.stderr.write(`Sign in to OpenStory: open ${code.verifyUrl} and enter ${code.userCode}\n`);
|
|
124
|
+
options.open(code.verifyUrl);
|
|
125
|
+
const poll = new URL('/api/v1/device/token', origin);
|
|
126
|
+
poll.searchParams.set('device_code', code.deviceCode);
|
|
127
|
+
poll.searchParams.set('wait', DEVICE_WAIT);
|
|
128
|
+
for (;;) {
|
|
129
|
+
const response = await fetchFn(poll);
|
|
130
|
+
if (response.ok) {
|
|
131
|
+
const key = stringOf(recordOf(await response.json()).api_key, 'a key');
|
|
132
|
+
await storeKey(options.home, origin, key);
|
|
133
|
+
options.stderr.write('Signed in.\n');
|
|
134
|
+
return key;
|
|
135
|
+
}
|
|
136
|
+
// OpenStory long-polls on `wait`, so a 428 comes back after a minute; a
|
|
137
|
+
// server that answers at once is paced by its interval, like a 429.
|
|
138
|
+
if (response.status === 428 || response.status === 429) {
|
|
139
|
+
await sleep(code.interval);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
throw new Error(response.status === 403
|
|
143
|
+
? 'The sign-in was denied'
|
|
144
|
+
: response.status === 410
|
|
145
|
+
? 'The sign-in code expired. Run the command again'
|
|
146
|
+
: `Sign-in failed (${response.status}) ${await errorText(response)}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
package/dist/pull.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** `openstory pull`: download one sequence's files into a folder. */
|
|
2
|
+
import type { Command } from './context.js';
|
|
3
|
+
export type PullArgs = {
|
|
4
|
+
sequenceId: string;
|
|
5
|
+
origin: string;
|
|
6
|
+
dir: string;
|
|
7
|
+
history: boolean;
|
|
8
|
+
exports: boolean;
|
|
9
|
+
};
|
|
10
|
+
export declare function parsePull(args: string[], cwd: string): PullArgs;
|
|
11
|
+
export declare const pull: Command;
|
package/dist/pull.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** `openstory pull`: download one sequence's files into a folder. */
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { downloadSequenceAssets } from './assets.js';
|
|
4
|
+
import { parseOrigin } from './context.js';
|
|
5
|
+
import { openStoryApiKey, signIn } from './login.js';
|
|
6
|
+
export function parsePull(args, cwd) {
|
|
7
|
+
const { origin, rest: argv } = parseOrigin(args);
|
|
8
|
+
let dir = null;
|
|
9
|
+
let history = false;
|
|
10
|
+
let exportsFlag = false;
|
|
11
|
+
const rest = [];
|
|
12
|
+
for (let i = 0; i < argv.length; i++) {
|
|
13
|
+
const arg = argv[i] ?? '';
|
|
14
|
+
if (arg === '--dir') {
|
|
15
|
+
const value = argv[i + 1];
|
|
16
|
+
if (value === undefined || value.startsWith('-')) {
|
|
17
|
+
throw new Error('--dir needs a directory');
|
|
18
|
+
}
|
|
19
|
+
dir = resolve(cwd, value);
|
|
20
|
+
i += 1;
|
|
21
|
+
}
|
|
22
|
+
else if (arg === '--history')
|
|
23
|
+
history = true;
|
|
24
|
+
else if (arg === '--exports')
|
|
25
|
+
exportsFlag = true;
|
|
26
|
+
else if (arg.startsWith('-'))
|
|
27
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
28
|
+
else
|
|
29
|
+
rest.push(arg);
|
|
30
|
+
}
|
|
31
|
+
const sequenceId = rest[0];
|
|
32
|
+
if (sequenceId === undefined) {
|
|
33
|
+
throw new Error('openstory pull needs a sequence id');
|
|
34
|
+
}
|
|
35
|
+
if (rest.length > 1)
|
|
36
|
+
throw new Error(`Unknown argument: ${rest[1]}`);
|
|
37
|
+
return {
|
|
38
|
+
sequenceId,
|
|
39
|
+
origin,
|
|
40
|
+
dir: dir ?? resolve(cwd, sequenceId),
|
|
41
|
+
history,
|
|
42
|
+
exports: exportsFlag,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export const pull = {
|
|
46
|
+
usage: 'openstory pull <sequenceId> [--history] [--exports] [--origin url] [--dir dir]',
|
|
47
|
+
async run(args, context) {
|
|
48
|
+
const request = parsePull(args, context.cwd);
|
|
49
|
+
const auth = { ...context, origin: request.origin };
|
|
50
|
+
const result = await downloadSequenceAssets({
|
|
51
|
+
sequenceId: request.sequenceId,
|
|
52
|
+
origin: request.origin,
|
|
53
|
+
cacheDir: join(context.home, '.openstory', 'assets'),
|
|
54
|
+
dest: request.dir,
|
|
55
|
+
history: request.history,
|
|
56
|
+
includeExports: request.exports,
|
|
57
|
+
fetch: context.fetch,
|
|
58
|
+
key: () => openStoryApiKey(auth),
|
|
59
|
+
refresh: () => signIn(auth),
|
|
60
|
+
});
|
|
61
|
+
context.stdout.write(`Saved ${result.saved} files. Skipped ${result.skipped} already there.${result.removed > 0 ? ` Removed ${result.removed} no longer listed.` : ''}\n`);
|
|
62
|
+
},
|
|
63
|
+
};
|
package/dist/share.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `openstory share`: upload a cut to OpenStory and get a link anyone can open.
|
|
3
|
+
*
|
|
4
|
+
* A share is three steps: POST /api/v1/stitches with the cut, whose media
|
|
5
|
+
* URLs are the bundle's `media/<id>.<ext>` names, one PUT per file to the
|
|
6
|
+
* upload link the response carries, and a POST to its publish link, which
|
|
7
|
+
* checks the files and the stills and makes the page live. The server
|
|
8
|
+
* rewrites the names to its own storage URLs; the printed link is the player
|
|
9
|
+
* page for that cut. The first share signs in (see login.ts).
|
|
10
|
+
*/
|
|
11
|
+
import type { ServedCut } from '@openstory/stitch/bundle';
|
|
12
|
+
import type { CliContext, Command } from './context.js';
|
|
13
|
+
export type ShareOptions = Pick<CliContext, 'home' | 'stderr' | 'open' | 'fetch'> & {
|
|
14
|
+
cut: ServedCut;
|
|
15
|
+
origin: string;
|
|
16
|
+
/** `--portrait-rights`: the basis for using a real person's likeness. */
|
|
17
|
+
portraitRights: string | null;
|
|
18
|
+
};
|
|
19
|
+
export declare const share: Command;
|
|
20
|
+
/** Uploads the cut and returns the public link. */
|
|
21
|
+
export declare function shareCut(options: ShareOptions): Promise<string>;
|
package/dist/share.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `openstory share`: upload a cut to OpenStory and get a link anyone can open.
|
|
3
|
+
*
|
|
4
|
+
* A share is three steps: POST /api/v1/stitches with the cut, whose media
|
|
5
|
+
* URLs are the bundle's `media/<id>.<ext>` names, one PUT per file to the
|
|
6
|
+
* upload link the response carries, and a POST to its publish link, which
|
|
7
|
+
* checks the files and the stills and makes the page live. The server
|
|
8
|
+
* rewrites the names to its own storage URLs; the printed link is the player
|
|
9
|
+
* page for that cut. The first share signs in (see login.ts).
|
|
10
|
+
*/
|
|
11
|
+
import { buildCut, bundledMediaUrl, cutJson, extensionOf, mediaType, parseCut, readBytes, } from '@openstory/stitch/bundle';
|
|
12
|
+
import { errorInfo, errorText, recordOf } from './api.js';
|
|
13
|
+
import { parseOrigin } from './context.js';
|
|
14
|
+
import { signIn, storedKey } from './login.js';
|
|
15
|
+
export const share = {
|
|
16
|
+
usage: 'openstory share [--origin url] [--portrait-rights basis] [cut options] <file>...',
|
|
17
|
+
async run(args, context) {
|
|
18
|
+
const { origin, rest } = parseOrigin(args);
|
|
19
|
+
let portraitRights = null;
|
|
20
|
+
const cutArgs = [];
|
|
21
|
+
for (let i = 0; i < rest.length; i++) {
|
|
22
|
+
const arg = rest[i] ?? '';
|
|
23
|
+
if (arg === '--portrait-rights') {
|
|
24
|
+
const value = rest[i + 1]?.trim();
|
|
25
|
+
if (value === undefined || value === '' || value.startsWith('-')) {
|
|
26
|
+
throw new Error('--portrait-rights needs the basis for using the person’s likeness (a release reference, "self", a contract id)');
|
|
27
|
+
}
|
|
28
|
+
portraitRights = value;
|
|
29
|
+
i += 1;
|
|
30
|
+
}
|
|
31
|
+
else
|
|
32
|
+
cutArgs.push(arg);
|
|
33
|
+
}
|
|
34
|
+
const cut = await buildCut(await parseCut(cutArgs, context.cwd), bundledMediaUrl);
|
|
35
|
+
const url = await shareCut({ ...context, cut, origin, portraitRights });
|
|
36
|
+
context.stdout.write(`${url}\n`);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
/** The extensions OpenStory stores for a stitch; the same list as its cut schema. */
|
|
40
|
+
const SHARED_EXTENSIONS = new Set([
|
|
41
|
+
'.jpg',
|
|
42
|
+
'.jpeg',
|
|
43
|
+
'.png',
|
|
44
|
+
'.webp',
|
|
45
|
+
'.gif',
|
|
46
|
+
'.mp4',
|
|
47
|
+
'.webm',
|
|
48
|
+
'.mov',
|
|
49
|
+
'.mp3',
|
|
50
|
+
'.wav',
|
|
51
|
+
'.ogg',
|
|
52
|
+
'.m4a',
|
|
53
|
+
]);
|
|
54
|
+
/** Uploads the cut and returns the public link. */
|
|
55
|
+
export async function shareCut(options) {
|
|
56
|
+
const fetchFn = options.fetch;
|
|
57
|
+
const origin = options.origin;
|
|
58
|
+
for (const file of options.cut.files.values()) {
|
|
59
|
+
const ext = extensionOf(file);
|
|
60
|
+
if (ext === '') {
|
|
61
|
+
throw new Error(`${file} has no file extension; share a copy that has one`);
|
|
62
|
+
}
|
|
63
|
+
if (!SHARED_EXTENSIONS.has(ext)) {
|
|
64
|
+
throw new Error(`${file}: ${ext} is not a type the shared player plays`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
let key = await storedKey(options.home, origin);
|
|
68
|
+
let fresh = false;
|
|
69
|
+
if (key === null) {
|
|
70
|
+
key = await signIn(options);
|
|
71
|
+
fresh = true;
|
|
72
|
+
}
|
|
73
|
+
let created = await createStitch(fetchFn, origin, key, options.cut);
|
|
74
|
+
if (!created.ok && created.status === 401 && !fresh) {
|
|
75
|
+
// The stored key was revoked. Sign in once more, then give up.
|
|
76
|
+
key = await signIn(options);
|
|
77
|
+
created = await createStitch(fetchFn, origin, key, options.cut);
|
|
78
|
+
}
|
|
79
|
+
if (!created.ok) {
|
|
80
|
+
throw created.status === 401
|
|
81
|
+
? new Error('OpenStory refused the key')
|
|
82
|
+
: created.error;
|
|
83
|
+
}
|
|
84
|
+
const { url, uploadHref, publishHref } = created;
|
|
85
|
+
for (const [id, file] of options.cut.files) {
|
|
86
|
+
const name = bundledMediaUrl(id, file).slice('media/'.length);
|
|
87
|
+
const type = mediaType(file);
|
|
88
|
+
const target = new URL(uploadHref
|
|
89
|
+
.replace('{name}', encodeURIComponent(name))
|
|
90
|
+
.replace('{type}', encodeURIComponent(type)), origin);
|
|
91
|
+
// ponytail: the whole file sits in memory for the PUT; the upload route
|
|
92
|
+
// takes one body up to ~100 MB. Stream and use the multipart route past that.
|
|
93
|
+
const response = await fetchFn(target, {
|
|
94
|
+
method: 'PUT',
|
|
95
|
+
headers: { Authorization: `Bearer ${key}`, 'Content-Type': type },
|
|
96
|
+
body: new Uint8Array(await readBytes(file)),
|
|
97
|
+
});
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
throw new Error(`${file}: upload failed (${response.status}) ${await errorText(response)}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const published = await fetchFn(new URL(publishHref, origin), {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: {
|
|
105
|
+
Authorization: `Bearer ${key}`,
|
|
106
|
+
'Content-Type': 'application/json',
|
|
107
|
+
},
|
|
108
|
+
body: JSON.stringify({
|
|
109
|
+
portraitAttestation: options.portraitRights === null
|
|
110
|
+
? null
|
|
111
|
+
: {
|
|
112
|
+
statementVersion: 'portrait-rights-v1',
|
|
113
|
+
authorizationBasis: options.portraitRights,
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
if (!published.ok) {
|
|
118
|
+
const { code, text } = await errorInfo(published);
|
|
119
|
+
throw new Error(code === 'ATTESTATION_REQUIRED'
|
|
120
|
+
? `${text} Run openstory share again with --portrait-rights <basis>.`
|
|
121
|
+
: `OpenStory did not publish the cut (${published.status}) ${text}`);
|
|
122
|
+
}
|
|
123
|
+
return url;
|
|
124
|
+
}
|
|
125
|
+
async function createStitch(fetchFn, origin, key, cut) {
|
|
126
|
+
const response = await fetchFn(new URL('/api/v1/stitches', origin), {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: {
|
|
129
|
+
Authorization: `Bearer ${key}`,
|
|
130
|
+
'Content-Type': 'application/json',
|
|
131
|
+
},
|
|
132
|
+
body: JSON.stringify({ cut: JSON.parse(cutJson(cut)) }),
|
|
133
|
+
});
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
status: response.status,
|
|
138
|
+
error: new Error(`OpenStory did not take the cut (${response.status}) ${await errorText(response)}`),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const record = recordOf(await response.json());
|
|
142
|
+
const url = record.url;
|
|
143
|
+
const links = recordOf(record._links);
|
|
144
|
+
const uploadHref = recordOf(links.upload).href;
|
|
145
|
+
const publishHref = recordOf(links.publish).href;
|
|
146
|
+
if (typeof url !== 'string' ||
|
|
147
|
+
typeof uploadHref !== 'string' ||
|
|
148
|
+
typeof publishHref !== 'string') {
|
|
149
|
+
throw new Error('OpenStory answered without a link to upload to');
|
|
150
|
+
}
|
|
151
|
+
return { ok: true, url, uploadHref, publishHref };
|
|
152
|
+
}
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `openstory sync`: a one-way mirror of an organization's sequences into
|
|
3
|
+
* `<dir>/<organization>/<sequence-slug>/` (#2222). OpenStory writes; the folder
|
|
4
|
+
* is output only and nothing reads it back. A full pass runs at start, then a
|
|
5
|
+
* sequence is synced again a couple of seconds after its realtime events
|
|
6
|
+
* settle. Every pass is safe to repeat, so a missed event is fixed by the next.
|
|
7
|
+
*
|
|
8
|
+
* `<dir>/.openstory/sync.json` maps organization and sequence ids to folders,
|
|
9
|
+
* so a slug change renames the folder. Archived or deleted sequences drop out
|
|
10
|
+
* of the list and their folders are left alone.
|
|
11
|
+
*/
|
|
12
|
+
import type { Command } from './context.js';
|
|
13
|
+
export type SyncState = {
|
|
14
|
+
v: 1;
|
|
15
|
+
origin: string;
|
|
16
|
+
organizations: Record<string, string>;
|
|
17
|
+
sequences: Record<string, {
|
|
18
|
+
organization: string;
|
|
19
|
+
folder: string;
|
|
20
|
+
}>;
|
|
21
|
+
};
|
|
22
|
+
export declare function readSyncState(dir: string, origin: string): Promise<SyncState>;
|
|
23
|
+
/** The ids each `data:` frame of an SSE chunk names as its channel. */
|
|
24
|
+
export declare function channelsInFrames(text: string): string[];
|
|
25
|
+
/** Runs `job` once per id, `SETTLE_MS` after its last event, one job at a time. */
|
|
26
|
+
export declare function settler(job: (id: string) => Promise<void>, onError: (error: unknown) => void, settleMs?: number): (id: string) => void;
|
|
27
|
+
export declare const sync: Command;
|