@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/sync.js
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
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 { existsSync } from 'node:fs';
|
|
13
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { dirname, join, resolve } from 'node:path';
|
|
15
|
+
import { recordOf, stringOf } from './api.js';
|
|
16
|
+
import { downloadSequenceAssets } from './assets.js';
|
|
17
|
+
import { parseOrigin } from './context.js';
|
|
18
|
+
import { isMissingFile, openStoryApiKey, signIn } from './login.js';
|
|
19
|
+
const SETTLE_MS = 2000;
|
|
20
|
+
const RELIST_MS = 60_000;
|
|
21
|
+
/** The realtime route takes at most this many channels per connection. */
|
|
22
|
+
const MAX_CHANNELS = 64;
|
|
23
|
+
function parseSync(args, home, cwd) {
|
|
24
|
+
const { origin, rest } = parseOrigin(args);
|
|
25
|
+
let dir = join(home, 'openstory');
|
|
26
|
+
let once = false;
|
|
27
|
+
for (let i = 0; i < rest.length; i++) {
|
|
28
|
+
const arg = rest[i] ?? '';
|
|
29
|
+
if (arg === '--dir') {
|
|
30
|
+
const value = rest[i + 1];
|
|
31
|
+
if (value === undefined || value.startsWith('-')) {
|
|
32
|
+
throw new Error('--dir needs a directory');
|
|
33
|
+
}
|
|
34
|
+
dir = resolve(cwd, value);
|
|
35
|
+
i += 1;
|
|
36
|
+
}
|
|
37
|
+
else if (arg === '--once')
|
|
38
|
+
once = true;
|
|
39
|
+
else
|
|
40
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
41
|
+
}
|
|
42
|
+
return { origin, dir, once };
|
|
43
|
+
}
|
|
44
|
+
const stateFile = (dir) => join(dir, '.openstory', 'sync.json');
|
|
45
|
+
export async function readSyncState(dir, origin) {
|
|
46
|
+
let text;
|
|
47
|
+
try {
|
|
48
|
+
text = await readFile(stateFile(dir), 'utf8');
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
// Only a missing file is a first run; anything else would lose the folder map.
|
|
52
|
+
if (isMissingFile(error)) {
|
|
53
|
+
return { v: 1, origin, organizations: {}, sequences: {} };
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`${stateFile(dir)} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
|
|
56
|
+
}
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = recordOf(JSON.parse(text));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new Error(`${stateFile(dir)} is not JSON; fix or remove it`);
|
|
63
|
+
}
|
|
64
|
+
if (parsed.origin !== origin) {
|
|
65
|
+
throw new Error(`${dir} mirrors ${String(parsed.origin)}. Pick another --dir for ${origin}.`);
|
|
66
|
+
}
|
|
67
|
+
const sequences = {};
|
|
68
|
+
for (const [id, value] of Object.entries(recordOf(parsed.sequences))) {
|
|
69
|
+
const row = recordOf(value);
|
|
70
|
+
if (typeof row.organization === 'string' &&
|
|
71
|
+
typeof row.folder === 'string') {
|
|
72
|
+
sequences[id] = { organization: row.organization, folder: row.folder };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const organizations = {};
|
|
76
|
+
for (const [id, folder] of Object.entries(recordOf(parsed.organizations))) {
|
|
77
|
+
if (typeof folder === 'string')
|
|
78
|
+
organizations[id] = folder;
|
|
79
|
+
}
|
|
80
|
+
return { v: 1, origin, organizations, sequences };
|
|
81
|
+
}
|
|
82
|
+
async function writeSyncState(dir, state) {
|
|
83
|
+
const file = stateFile(dir);
|
|
84
|
+
await mkdir(dirname(file), { recursive: true });
|
|
85
|
+
await writeFile(`${file}.partial`, `${JSON.stringify(state, null, 2)}\n`);
|
|
86
|
+
await rename(`${file}.partial`, file);
|
|
87
|
+
}
|
|
88
|
+
/** Moves `from` to `to` under `dir`; both present is a clash the user settles. */
|
|
89
|
+
async function renameFolder(dir, from, to) {
|
|
90
|
+
if (from === to)
|
|
91
|
+
return;
|
|
92
|
+
const source = join(dir, from);
|
|
93
|
+
const target = join(dir, to);
|
|
94
|
+
if (!existsSync(source))
|
|
95
|
+
return;
|
|
96
|
+
if (existsSync(target)) {
|
|
97
|
+
throw new Error(`${source} was renamed to ${target}, which already exists. Move or delete one, then sync again.`);
|
|
98
|
+
}
|
|
99
|
+
await mkdir(dirname(target), { recursive: true });
|
|
100
|
+
await rename(source, target);
|
|
101
|
+
}
|
|
102
|
+
/** One folder name segment: never a path, never hidden. */
|
|
103
|
+
function folderName(value) {
|
|
104
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(value)) {
|
|
105
|
+
throw new Error(`OpenStory answered with an unusable folder name: ${value}`);
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
class Mirror {
|
|
110
|
+
context;
|
|
111
|
+
args;
|
|
112
|
+
state;
|
|
113
|
+
key = null;
|
|
114
|
+
organization = null;
|
|
115
|
+
constructor(context, args, state) {
|
|
116
|
+
this.context = context;
|
|
117
|
+
this.args = args;
|
|
118
|
+
this.state = state;
|
|
119
|
+
}
|
|
120
|
+
auth() {
|
|
121
|
+
return { ...this.context, origin: this.args.origin };
|
|
122
|
+
}
|
|
123
|
+
/** A GET with the stored key; a 401 signs in once more. */
|
|
124
|
+
async get(path) {
|
|
125
|
+
this.key ??= await openStoryApiKey(this.auth());
|
|
126
|
+
const run = (key) => this.context.fetch(new URL(path, this.args.origin), {
|
|
127
|
+
headers: { authorization: `Bearer ${key}` },
|
|
128
|
+
});
|
|
129
|
+
let response = await run(this.key);
|
|
130
|
+
if (response.status === 401) {
|
|
131
|
+
this.key = await signIn(this.auth());
|
|
132
|
+
response = await run(this.key);
|
|
133
|
+
}
|
|
134
|
+
return response;
|
|
135
|
+
}
|
|
136
|
+
async json(path) {
|
|
137
|
+
const response = await this.get(path);
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
throw new Error(`OpenStory answered ${response.status} for ${path}`);
|
|
140
|
+
}
|
|
141
|
+
return recordOf(await response.json());
|
|
142
|
+
}
|
|
143
|
+
/** The key's organization folder: `personal` for the sign-up one, else its slug. */
|
|
144
|
+
async syncOrganization() {
|
|
145
|
+
const org = await this.json('/api/v1/organization');
|
|
146
|
+
const id = stringOf(org.id, 'an organization id');
|
|
147
|
+
const folder = org.personal === true
|
|
148
|
+
? 'personal'
|
|
149
|
+
: folderName(stringOf(org.slug, 'an organization slug'));
|
|
150
|
+
const previous = this.state.organizations[id];
|
|
151
|
+
if (previous !== undefined) {
|
|
152
|
+
await renameFolder(this.args.dir, previous, folder);
|
|
153
|
+
for (const row of Object.values(this.state.sequences)) {
|
|
154
|
+
if (row.organization === id) {
|
|
155
|
+
row.folder = join(folder, row.folder.slice(previous.length + 1));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
this.state.organizations[id] = folder;
|
|
160
|
+
this.organization = { id, folder };
|
|
161
|
+
await writeSyncState(this.args.dir, this.state);
|
|
162
|
+
return folder;
|
|
163
|
+
}
|
|
164
|
+
async listSequences() {
|
|
165
|
+
const found = [];
|
|
166
|
+
let path = '/api/v1/sequences?limit=100';
|
|
167
|
+
while (path !== null) {
|
|
168
|
+
const page = await this.json(path);
|
|
169
|
+
const rows = page.sequences;
|
|
170
|
+
if (!Array.isArray(rows)) {
|
|
171
|
+
throw new Error(`OpenStory answered without a sequence list for ${path}`);
|
|
172
|
+
}
|
|
173
|
+
for (const value of rows) {
|
|
174
|
+
const row = recordOf(value);
|
|
175
|
+
found.push({
|
|
176
|
+
id: stringOf(row.id, 'a sequence id'),
|
|
177
|
+
slug: folderName(stringOf(row.slug, 'a sequence slug')),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const next = recordOf(recordOf(page._links).next).href;
|
|
181
|
+
path = typeof next === 'string' ? next : null;
|
|
182
|
+
}
|
|
183
|
+
return found;
|
|
184
|
+
}
|
|
185
|
+
/** One sequence's id and slug; null once it is archived or deleted. */
|
|
186
|
+
async getSequence(id) {
|
|
187
|
+
const response = await this.get(`/api/v1/sequences/${encodeURIComponent(id)}`);
|
|
188
|
+
if (response.status === 404)
|
|
189
|
+
return null;
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
throw new Error(`OpenStory answered ${response.status} for ${id}`);
|
|
192
|
+
}
|
|
193
|
+
const row = recordOf(await response.json());
|
|
194
|
+
if (row.status === 'archived')
|
|
195
|
+
return null;
|
|
196
|
+
return {
|
|
197
|
+
id: stringOf(row.id, 'a sequence id'),
|
|
198
|
+
slug: folderName(stringOf(row.slug, 'a sequence slug')),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
async syncSequence(sequence) {
|
|
202
|
+
const organization = this.organization;
|
|
203
|
+
if (organization === null)
|
|
204
|
+
throw new Error('Sync the organization first');
|
|
205
|
+
const folder = join(organization.folder, sequence.slug);
|
|
206
|
+
const previous = this.state.sequences[sequence.id];
|
|
207
|
+
if (previous)
|
|
208
|
+
await renameFolder(this.args.dir, previous.folder, folder);
|
|
209
|
+
this.state.sequences[sequence.id] = {
|
|
210
|
+
organization: organization.id,
|
|
211
|
+
folder,
|
|
212
|
+
};
|
|
213
|
+
await writeSyncState(this.args.dir, this.state);
|
|
214
|
+
const result = await downloadSequenceAssets({
|
|
215
|
+
sequenceId: sequence.id,
|
|
216
|
+
origin: this.args.origin,
|
|
217
|
+
cacheDir: join(this.args.dir, '.openstory', 'cache'),
|
|
218
|
+
dest: join(this.args.dir, folder),
|
|
219
|
+
history: false,
|
|
220
|
+
includeExports: false,
|
|
221
|
+
fetch: this.context.fetch,
|
|
222
|
+
key: async () => (this.key ??= await openStoryApiKey(this.auth())),
|
|
223
|
+
refresh: async () => (this.key = await signIn(this.auth())),
|
|
224
|
+
});
|
|
225
|
+
if (result.saved + result.removed > 0) {
|
|
226
|
+
this.context.stdout.write(`${folder}: saved ${result.saved}, removed ${result.removed}\n`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Startup pass: every listed sequence, unchanged files skipped. */
|
|
230
|
+
async fullPass() {
|
|
231
|
+
await this.syncOrganization();
|
|
232
|
+
const sequences = await this.listSequences();
|
|
233
|
+
for (const sequence of sequences)
|
|
234
|
+
await this.syncSequence(sequence);
|
|
235
|
+
return sequences;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** The ids each `data:` frame of an SSE chunk names as its channel. */
|
|
239
|
+
export function channelsInFrames(text) {
|
|
240
|
+
const channels = [];
|
|
241
|
+
for (const line of text.split('\n')) {
|
|
242
|
+
if (!line.startsWith('data:'))
|
|
243
|
+
continue;
|
|
244
|
+
try {
|
|
245
|
+
const channel = recordOf(JSON.parse(line.slice(5))).channel;
|
|
246
|
+
if (typeof channel === 'string')
|
|
247
|
+
channels.push(channel);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// A partial or non-JSON frame carries no channel.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return channels;
|
|
254
|
+
}
|
|
255
|
+
/** Runs `job` once per id, `SETTLE_MS` after its last event, one job at a time. */
|
|
256
|
+
export function settler(job, onError, settleMs = SETTLE_MS) {
|
|
257
|
+
const timers = new Map();
|
|
258
|
+
let chain = Promise.resolve();
|
|
259
|
+
return (id) => {
|
|
260
|
+
clearTimeout(timers.get(id));
|
|
261
|
+
timers.set(id, setTimeout(() => {
|
|
262
|
+
timers.delete(id);
|
|
263
|
+
chain = chain.then(() => job(id)).catch(onError);
|
|
264
|
+
}, settleMs));
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
async function watch(mirror, start, context) {
|
|
268
|
+
let sequences = new Map(start.map((s) => [s.id, s]));
|
|
269
|
+
const report = (error) => context.stderr.write(`sync: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
270
|
+
const resync = settler(async (id) => {
|
|
271
|
+
if (!sequences.has(id))
|
|
272
|
+
return;
|
|
273
|
+
// The current slug, so a rename moves the folder now, not at the next list.
|
|
274
|
+
const sequence = await mirror.getSequence(id);
|
|
275
|
+
if (sequence)
|
|
276
|
+
await mirror.syncSequence(sequence);
|
|
277
|
+
}, report);
|
|
278
|
+
let abort = new AbortController();
|
|
279
|
+
// ponytail: one connection with the newest 64 sequences; older ones resync on the next start.
|
|
280
|
+
const listen = async () => {
|
|
281
|
+
for (let delay = 1000;; delay = Math.min(delay * 2, 30_000)) {
|
|
282
|
+
const ids = [...sequences.keys()].slice(0, MAX_CHANNELS);
|
|
283
|
+
const signal = abort.signal;
|
|
284
|
+
try {
|
|
285
|
+
if (ids.length > 0) {
|
|
286
|
+
const response = await mirror.get(`/api/realtime?channels=${ids.map(encodeURIComponent).join(',')}`);
|
|
287
|
+
if (!response.ok || !response.body) {
|
|
288
|
+
throw new Error(`realtime answered ${response.status}`);
|
|
289
|
+
}
|
|
290
|
+
delay = 1000;
|
|
291
|
+
const reader = response.body.getReader();
|
|
292
|
+
signal.addEventListener('abort', () => void reader.cancel());
|
|
293
|
+
if (signal.aborted)
|
|
294
|
+
void reader.cancel();
|
|
295
|
+
const decoder = new TextDecoder();
|
|
296
|
+
let buffer = '';
|
|
297
|
+
for (;;) {
|
|
298
|
+
const { done, value } = await reader.read();
|
|
299
|
+
if (done)
|
|
300
|
+
break;
|
|
301
|
+
buffer += decoder.decode(value, { stream: true });
|
|
302
|
+
const end = buffer.lastIndexOf('\n\n');
|
|
303
|
+
if (end === -1)
|
|
304
|
+
continue;
|
|
305
|
+
for (const id of channelsInFrames(buffer.slice(0, end))) {
|
|
306
|
+
resync(id);
|
|
307
|
+
}
|
|
308
|
+
buffer = buffer.slice(end + 2);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
if (!signal.aborted)
|
|
314
|
+
report(error);
|
|
315
|
+
}
|
|
316
|
+
if (!signal.aborted) {
|
|
317
|
+
await new Promise((r) => setTimeout(r, ids.length ? delay : RELIST_MS));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
void listen();
|
|
322
|
+
for (;;) {
|
|
323
|
+
await new Promise((r) => setTimeout(r, RELIST_MS));
|
|
324
|
+
try {
|
|
325
|
+
await mirror.syncOrganization();
|
|
326
|
+
const listed = await mirror.listSequences();
|
|
327
|
+
const added = listed.filter((s) => {
|
|
328
|
+
const known = sequences.get(s.id);
|
|
329
|
+
return known === undefined || known.slug !== s.slug;
|
|
330
|
+
});
|
|
331
|
+
sequences = new Map(listed.map((s) => [s.id, s]));
|
|
332
|
+
for (const sequence of added)
|
|
333
|
+
resync(sequence.id);
|
|
334
|
+
if (added.length > 0) {
|
|
335
|
+
abort.abort();
|
|
336
|
+
abort = new AbortController();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
report(error);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
export const sync = {
|
|
345
|
+
usage: 'openstory sync [--dir dir] [--once] [--origin url]',
|
|
346
|
+
async run(args, context) {
|
|
347
|
+
const request = parseSync(args, context.home, context.cwd);
|
|
348
|
+
const state = await readSyncState(request.dir, request.origin);
|
|
349
|
+
const mirror = new Mirror(context, request, state);
|
|
350
|
+
const sequences = await mirror.fullPass();
|
|
351
|
+
context.stdout.write(`Synced ${sequences.length} sequences into ${request.dir}\n`);
|
|
352
|
+
if (!request.once)
|
|
353
|
+
await watch(mirror, sequences, context);
|
|
354
|
+
},
|
|
355
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** At most one npm registry check a day; a newer version becomes a one-line hint. Never updates itself. */
|
|
2
|
+
export type UpdateCheckOptions = {
|
|
3
|
+
home: string;
|
|
4
|
+
current: string;
|
|
5
|
+
now: number;
|
|
6
|
+
/** Plain fetch: the registry never gets the OpenStory version header. */
|
|
7
|
+
fetch: typeof fetch;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
};
|
|
10
|
+
/** The hint to print, or null. Swallows every error. */
|
|
11
|
+
export declare function updateNotice(options: UpdateCheckOptions): Promise<string | null>;
|
|
12
|
+
/** `a` > `b` on major.minor.patch; anything unreadable is not newer. */
|
|
13
|
+
export declare function isNewer(a: string, b: string): boolean;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** At most one npm registry check a day; a newer version becomes a one-line hint. Never updates itself. */
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { recordOf } from './api.js';
|
|
5
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/@openstory/cli/latest';
|
|
7
|
+
const CACHE_FILE = ['.openstory', 'cli-update.json'];
|
|
8
|
+
/** The hint to print, or null. Swallows every error. */
|
|
9
|
+
export async function updateNotice(options) {
|
|
10
|
+
try {
|
|
11
|
+
const latest = await latestVersion(options);
|
|
12
|
+
return latest !== null && isNewer(latest, options.current)
|
|
13
|
+
? `openstory ${latest} is out (you have ${options.current}): npx @openstory/cli@latest\n`
|
|
14
|
+
: null;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function latestVersion(options) {
|
|
21
|
+
const file = join(options.home, ...CACHE_FILE);
|
|
22
|
+
const cached = recordOf(JSON.parse(await readFile(file, 'utf8').catch(() => '{}')));
|
|
23
|
+
const cachedLatest = typeof cached.latest === 'string' ? cached.latest : null;
|
|
24
|
+
if (typeof cached.checkedAt === 'number' &&
|
|
25
|
+
options.now - cached.checkedAt < DAY_MS) {
|
|
26
|
+
return cachedLatest;
|
|
27
|
+
}
|
|
28
|
+
// Stamp first, so a failed or slow check still waits a day.
|
|
29
|
+
await mkdir(join(options.home, CACHE_FILE[0]), { recursive: true });
|
|
30
|
+
await writeFile(file, JSON.stringify({ checkedAt: options.now, latest: cachedLatest }));
|
|
31
|
+
const response = await options.fetch(REGISTRY_URL, {
|
|
32
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 1500),
|
|
33
|
+
});
|
|
34
|
+
if (!response.ok)
|
|
35
|
+
return cachedLatest;
|
|
36
|
+
const latest = recordOf(await response.json()).version;
|
|
37
|
+
if (typeof latest !== 'string')
|
|
38
|
+
return cachedLatest;
|
|
39
|
+
await writeFile(file, JSON.stringify({ checkedAt: options.now, latest }));
|
|
40
|
+
return latest;
|
|
41
|
+
}
|
|
42
|
+
/** `a` > `b` on major.minor.patch; anything unreadable is not newer. */
|
|
43
|
+
export function isNewer(a, b) {
|
|
44
|
+
const parse = (v) => /^(\d+)\.(\d+)\.(\d+)/.exec(v)?.slice(1).map(Number);
|
|
45
|
+
const x = parse(a);
|
|
46
|
+
const y = parse(b);
|
|
47
|
+
if (!x || !y)
|
|
48
|
+
return false;
|
|
49
|
+
for (let i = 0; i < 3; i++) {
|
|
50
|
+
const d = (x[i] ?? 0) - (y[i] ?? 0);
|
|
51
|
+
if (d !== 0)
|
|
52
|
+
return d > 0;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openstory/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The OpenStory command line: sign in, download a sequence's files, and share a cut.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"openstory",
|
|
8
|
+
"stitch",
|
|
9
|
+
"video"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/openstory-so/openstory/tree/main/packages/cli#readme",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/openstory-so/openstory.git",
|
|
16
|
+
"directory": "packages/cli"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"openstory": "./bin/openstory.mjs"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"bin",
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
32
|
+
"prepack": "bun run build"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@openstory/stitch": "workspace:^"
|
|
36
|
+
}
|
|
37
|
+
}
|