@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/README.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @openstory/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The OpenStory command line: sign in, download a sequence's files, mirror your library, and share a cut. Nothing to install:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx @openstory/cli@latest <command>
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Keep `@latest`: npx and bunx can otherwise run a stale cached copy. The bin is `openstory`. The unscoped `openstory` package on npm is an unrelated project.
|
|
10
|
+
|
|
11
|
+
## Commands
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npx @openstory/cli@latest login
|
|
15
|
+
npx @openstory/cli@latest pull 01JSEQUENCE --dir ./room
|
|
16
|
+
npx @openstory/cli@latest sync
|
|
17
|
+
npx @openstory/cli@latest share --music score.mp3 shot.mp4 board.png --audio line.wav
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- `login` signs in with a device code: it prints a short code and opens the approval page in your browser. `pull` and `share` do the same on first use.
|
|
21
|
+
- `pull <sequenceId>` downloads the current cut into `./<sequenceId>` (or `--dir`). A later run skips a file that is already there at the same version. `--history` adds every completed version, including discarded ones. `--exports` adds rendered MP4s.
|
|
22
|
+
- `pull` names folders by order and title: `scenes/03-the-chase/shots/02-wide-marlowe-runs/still.png`, `cast/marlowe/looks/default/sheet.png`. A file the previous run wrote and the sequence no longer lists is removed; nothing else in the folder is touched.
|
|
23
|
+
- `sync` mirrors every sequence of your organization into `~/openstory/<organization>/<sequence>/` (or `--dir`) and keeps it current until you stop it. Your sign-up organization is `personal/`. A renamed sequence renames its folder; an archived one is left alone. The folder is output only: OpenStory never reads it back. `--once` stops after the first pass. One folder mirrors one server.
|
|
24
|
+
- `share …` takes the cut options of [`@openstory/stitch`](https://github.com/openstory-so/openstory/tree/main/packages/stitch#readme), uploads every file to your organization and prints `https://openstory.so/stitch/<id>`, a page anyone with the link can watch. Every still is checked for a real person; one who is needs `--portrait-rights <basis>`, the basis for using their likeness (a release reference, `self`, a contract id).
|
|
25
|
+
|
|
26
|
+
Every command takes `--origin <url>` to talk to another OpenStory server.
|
|
27
|
+
|
|
28
|
+
## Files
|
|
29
|
+
|
|
30
|
+
- `~/.openstory/credentials.json` holds one API key per server. A `~/.openstory/stitch.json` left by stitch 0.4 is renamed to it once.
|
|
31
|
+
- `~/.openstory/assets/` holds cast, location and style files once; `pull` links them into each folder. `sync` keeps its own in `<dir>/.openstory/cache`, beside `<dir>/.openstory/sync.json`, which maps each organization and sequence to its folder.
|
|
32
|
+
- `~/.openstory/cli-update.json` remembers the last check for a newer version. The CLI asks npm at most once a day, prints a one-line hint on stderr, and never updates itself. No check runs under `CI` or without a terminal.
|
|
33
|
+
|
|
34
|
+
If OpenStory answers that the CLI is too old, run it again with `npx @openstory/cli@latest`.
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
MIT
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Talking to OpenStory: the version header, the 426 answer, and the error envelope. */
|
|
2
|
+
/** Mirrors CLI_VERSION_HEADER on the server (src/platform/server/api-v1/cli-version.ts). */
|
|
3
|
+
export declare const CLI_VERSION_HEADER = "X-OpenStory-CLI";
|
|
4
|
+
export declare const CLI_VERSION: string;
|
|
5
|
+
/** Every request to OpenStory carries the CLI version; a 426 becomes the update message. */
|
|
6
|
+
export declare function versionedFetch(fetchFn: typeof fetch, version?: string): typeof fetch;
|
|
7
|
+
/** A JSON object's fields, or none: a wrong shape reads as missing fields. */
|
|
8
|
+
export declare function recordOf(value: unknown): Record<string, unknown>;
|
|
9
|
+
export declare function stringOf(value: unknown, label: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* The error envelope's message, with each field issue a 400 carries
|
|
12
|
+
* (`details.issues`, as `path: message`), else the raw body.
|
|
13
|
+
*/
|
|
14
|
+
export declare function errorText(response: Response): Promise<string>;
|
|
15
|
+
/** The envelope's `error.code` beside the text, for a branch that must not rest on wording. */
|
|
16
|
+
export declare function errorInfo(response: Response): Promise<{
|
|
17
|
+
code: string | null;
|
|
18
|
+
text: string;
|
|
19
|
+
}>;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Talking to OpenStory: the version header, the 426 answer, and the error envelope. */
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
/** Mirrors CLI_VERSION_HEADER on the server (src/platform/server/api-v1/cli-version.ts). */
|
|
4
|
+
export const CLI_VERSION_HEADER = 'X-OpenStory-CLI';
|
|
5
|
+
export const CLI_VERSION = readVersion();
|
|
6
|
+
function readVersion() {
|
|
7
|
+
const text = readFileSync(new URL('../package.json', import.meta.url), 'utf8');
|
|
8
|
+
const version = recordOf(JSON.parse(text)).version;
|
|
9
|
+
if (typeof version !== 'string')
|
|
10
|
+
throw new Error('package.json has no version');
|
|
11
|
+
return version;
|
|
12
|
+
}
|
|
13
|
+
/** Every request to OpenStory carries the CLI version; a 426 becomes the update message. */
|
|
14
|
+
export function versionedFetch(fetchFn, version = CLI_VERSION) {
|
|
15
|
+
return async (input, init) => {
|
|
16
|
+
const headers = new Headers(init?.headers);
|
|
17
|
+
headers.set(CLI_VERSION_HEADER, version);
|
|
18
|
+
const response = await fetchFn(input, { ...init, headers });
|
|
19
|
+
if (response.status === 426) {
|
|
20
|
+
throw new Error(`OpenStory needs a newer @openstory/cli (you have ${version}). Run npx @openstory/cli@latest. ${await errorText(response)}`.trim());
|
|
21
|
+
}
|
|
22
|
+
return response;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** A JSON object's fields, or none: a wrong shape reads as missing fields. */
|
|
26
|
+
export function recordOf(value) {
|
|
27
|
+
return typeof value === 'object' && value !== null
|
|
28
|
+
? Object.fromEntries(Object.entries(value))
|
|
29
|
+
: {};
|
|
30
|
+
}
|
|
31
|
+
export function stringOf(value, label) {
|
|
32
|
+
if (typeof value === 'string')
|
|
33
|
+
return value;
|
|
34
|
+
throw new Error(`OpenStory answered without ${label}`);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The error envelope's message, with each field issue a 400 carries
|
|
38
|
+
* (`details.issues`, as `path: message`), else the raw body.
|
|
39
|
+
*/
|
|
40
|
+
export async function errorText(response) {
|
|
41
|
+
return (await errorInfo(response)).text;
|
|
42
|
+
}
|
|
43
|
+
/** The envelope's `error.code` beside the text, for a branch that must not rest on wording. */
|
|
44
|
+
export async function errorInfo(response) {
|
|
45
|
+
const text = await response.text().catch(() => '');
|
|
46
|
+
try {
|
|
47
|
+
const parsed = recordOf(JSON.parse(text));
|
|
48
|
+
const error = recordOf(parsed.error);
|
|
49
|
+
const code = typeof error.code === 'string' ? error.code : null;
|
|
50
|
+
const message = typeof parsed.error === 'string'
|
|
51
|
+
? parsed.error
|
|
52
|
+
: (error.message ?? parsed.message);
|
|
53
|
+
if (typeof message !== 'string')
|
|
54
|
+
return { code, text };
|
|
55
|
+
const issues = recordOf(error.details).issues;
|
|
56
|
+
if (!Array.isArray(issues))
|
|
57
|
+
return { code, text: message };
|
|
58
|
+
const lines = issues.map((issue) => {
|
|
59
|
+
const record = recordOf(issue);
|
|
60
|
+
const path = Array.isArray(record.path) ? record.path.join('.') : '';
|
|
61
|
+
const text = typeof record.message === 'string' ? record.message : '';
|
|
62
|
+
return `${path}: ${text}`;
|
|
63
|
+
});
|
|
64
|
+
return { code, text: [message, ...lines].join('\n ') };
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return { code: null, text };
|
|
68
|
+
}
|
|
69
|
+
}
|
package/dist/assets.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download one sequence's files into a folder.
|
|
3
|
+
*
|
|
4
|
+
* The command asks the public API for the manifest, then writes each file.
|
|
5
|
+
* A second run skips a path whose versionId is unchanged and whose file is
|
|
6
|
+
* still there, and a path the previous manifest listed but this one does not
|
|
7
|
+
* is removed. Shared cast, location, and style files are stored once in the
|
|
8
|
+
* cache directory, named from the stored url, and linked into the folder.
|
|
9
|
+
* This file does not import the app: the skip rule is copied here on purpose.
|
|
10
|
+
*/
|
|
11
|
+
export type DownloadAssetsOptions = {
|
|
12
|
+
sequenceId: string;
|
|
13
|
+
origin: string;
|
|
14
|
+
/** Shared files live here once; `pull` passes ~/.openstory/assets. */
|
|
15
|
+
cacheDir: string;
|
|
16
|
+
dest: string;
|
|
17
|
+
history: boolean;
|
|
18
|
+
includeExports: boolean;
|
|
19
|
+
fetch?: typeof fetch;
|
|
20
|
+
key: () => Promise<string>;
|
|
21
|
+
refresh: () => Promise<string>;
|
|
22
|
+
};
|
|
23
|
+
export declare function downloadSequenceAssets(options: DownloadAssetsOptions): Promise<{
|
|
24
|
+
saved: number;
|
|
25
|
+
skipped: number;
|
|
26
|
+
removed: number;
|
|
27
|
+
}>;
|
package/dist/assets.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download one sequence's files into a folder.
|
|
3
|
+
*
|
|
4
|
+
* The command asks the public API for the manifest, then writes each file.
|
|
5
|
+
* A second run skips a path whose versionId is unchanged and whose file is
|
|
6
|
+
* still there, and a path the previous manifest listed but this one does not
|
|
7
|
+
* is removed. Shared cast, location, and style files are stored once in the
|
|
8
|
+
* cache directory, named from the stored url, and linked into the folder.
|
|
9
|
+
* This file does not import the app: the skip rule is copied here on purpose.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { once } from 'node:events';
|
|
13
|
+
import { createWriteStream } from 'node:fs';
|
|
14
|
+
import { copyFile, link, mkdir, readFile, realpath, rename, rm, rmdir, stat, writeFile, } from 'node:fs/promises';
|
|
15
|
+
import { dirname, extname, join, sep } from 'node:path';
|
|
16
|
+
export async function downloadSequenceAssets(options) {
|
|
17
|
+
const fetchFn = options.fetch ?? fetch;
|
|
18
|
+
const manifestUrl = new URL(`/api/v1/sequences/${encodeURIComponent(options.sequenceId)}/assets`, options.origin);
|
|
19
|
+
manifestUrl.searchParams.set('scope', options.history ? 'history' : 'selected');
|
|
20
|
+
if (options.includeExports)
|
|
21
|
+
manifestUrl.searchParams.set('exports', 'true');
|
|
22
|
+
let token = await options.key();
|
|
23
|
+
let refreshed = false;
|
|
24
|
+
const authed = async (url) => {
|
|
25
|
+
const run = (bearer) => fetchFn(url, { headers: { authorization: `Bearer ${bearer}` } });
|
|
26
|
+
let response = await run(token);
|
|
27
|
+
if (response.status === 401 && !refreshed) {
|
|
28
|
+
refreshed = true;
|
|
29
|
+
token = await options.refresh();
|
|
30
|
+
response = await run(token);
|
|
31
|
+
}
|
|
32
|
+
return response;
|
|
33
|
+
};
|
|
34
|
+
const listed = await authed(manifestUrl.href);
|
|
35
|
+
if (!listed.ok) {
|
|
36
|
+
throw new Error(`OpenStory could not list assets (${listed.status}) ${await errorText(listed)}`);
|
|
37
|
+
}
|
|
38
|
+
const manifest = await listed.json();
|
|
39
|
+
const assets = readAssets(manifest);
|
|
40
|
+
for (const asset of assets) {
|
|
41
|
+
if (!safeRelativePath(asset.relativePath)) {
|
|
42
|
+
throw new Error(`Refusing to write ${asset.relativePath}`);
|
|
43
|
+
}
|
|
44
|
+
if (asset.url != null && !asset.url.startsWith('/')) {
|
|
45
|
+
throw new Error(`Refusing ${asset.url}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const previous = await previousVersions(options.dest);
|
|
49
|
+
const present = new Set();
|
|
50
|
+
for (const asset of assets) {
|
|
51
|
+
if (previous.get(asset.relativePath) === asset.versionId &&
|
|
52
|
+
(await fileExists(options.dest, asset.relativePath))) {
|
|
53
|
+
present.add(asset.relativePath);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const todo = assets.filter((asset) => {
|
|
57
|
+
if (asset.url == null && asset.text == null)
|
|
58
|
+
return false;
|
|
59
|
+
return !present.has(asset.relativePath);
|
|
60
|
+
});
|
|
61
|
+
for (const asset of todo) {
|
|
62
|
+
const dest = join(options.dest, asset.relativePath);
|
|
63
|
+
const cache = asset.shared
|
|
64
|
+
? sharedCacheFile(options.cacheDir, asset)
|
|
65
|
+
: null;
|
|
66
|
+
if (asset.text != null) {
|
|
67
|
+
await writeCached(dest, cache, async (file) => {
|
|
68
|
+
await mkdir(dirname(file), { recursive: true });
|
|
69
|
+
await writeFile(file, asset.text ?? '', 'utf8');
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
else if (asset.url != null) {
|
|
73
|
+
const fileUrl = new URL(asset.url, options.origin).href;
|
|
74
|
+
await writeCached(dest, cache, (file) => downloadTo(file, fileUrl, authed));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Only paths an earlier manifest listed: anything else in the folder is the user's.
|
|
78
|
+
const listedNow = new Set(assets.map((asset) => asset.relativePath));
|
|
79
|
+
let removed = 0;
|
|
80
|
+
const root = await realpath(options.dest).catch(() => null);
|
|
81
|
+
for (const path of previous.keys()) {
|
|
82
|
+
if (root === null)
|
|
83
|
+
break;
|
|
84
|
+
if (listedNow.has(path) || !safeRelativePath(path))
|
|
85
|
+
continue;
|
|
86
|
+
if (!(await fileExists(options.dest, path)))
|
|
87
|
+
continue;
|
|
88
|
+
// A symlinked folder inside dest must not lead the delete outside it.
|
|
89
|
+
const target = await realpath(join(options.dest, path));
|
|
90
|
+
if (!target.startsWith(`${root}${sep}`))
|
|
91
|
+
continue;
|
|
92
|
+
await rm(target);
|
|
93
|
+
await pruneEmptyDirs(options.dest, dirname(path));
|
|
94
|
+
removed += 1;
|
|
95
|
+
}
|
|
96
|
+
await mkdir(options.dest, { recursive: true });
|
|
97
|
+
await writeFile(join(options.dest, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
98
|
+
const skipped = assets.length - todo.length;
|
|
99
|
+
return { saved: todo.length, skipped, removed };
|
|
100
|
+
}
|
|
101
|
+
/** Removes `dir` and its parents while empty, stopping at `root`. */
|
|
102
|
+
async function pruneEmptyDirs(root, dir) {
|
|
103
|
+
for (let rel = dir; rel !== '.' && rel !== ''; rel = dirname(rel)) {
|
|
104
|
+
try {
|
|
105
|
+
await rmdir(join(root, rel));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function writeCached(dest, cache, write) {
|
|
113
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
114
|
+
if (!cache) {
|
|
115
|
+
await write(dest);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
let cached = false;
|
|
119
|
+
try {
|
|
120
|
+
await stat(cache);
|
|
121
|
+
cached = true;
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (!isMissing(error))
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
if (!cached)
|
|
128
|
+
await write(cache);
|
|
129
|
+
await linkOver(cache, dest);
|
|
130
|
+
}
|
|
131
|
+
async function downloadTo(file, url, authed) {
|
|
132
|
+
const response = await authed(url);
|
|
133
|
+
if (!response.ok || response.body == null) {
|
|
134
|
+
throw new Error(`OpenStory could not download ${url} (${response.status}) ${await errorText(response)}`);
|
|
135
|
+
}
|
|
136
|
+
await mkdir(dirname(file), { recursive: true });
|
|
137
|
+
const partial = `${file}.partial`;
|
|
138
|
+
const writable = createWriteStream(partial);
|
|
139
|
+
const reader = response.body.getReader();
|
|
140
|
+
try {
|
|
141
|
+
for (;;) {
|
|
142
|
+
const { done, value } = await reader.read();
|
|
143
|
+
if (done)
|
|
144
|
+
break;
|
|
145
|
+
if (value && !writable.write(value))
|
|
146
|
+
await once(writable, 'drain');
|
|
147
|
+
}
|
|
148
|
+
writable.end();
|
|
149
|
+
await once(writable, 'finish');
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
writable.destroy();
|
|
153
|
+
await rm(partial, { force: true });
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
await rename(partial, file);
|
|
157
|
+
}
|
|
158
|
+
async function linkOver(from, to) {
|
|
159
|
+
await mkdir(dirname(to), { recursive: true });
|
|
160
|
+
await rm(to, { force: true });
|
|
161
|
+
try {
|
|
162
|
+
await link(from, to);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
const code = typeof error === 'object' && error !== null && 'code' in error
|
|
166
|
+
? error.code
|
|
167
|
+
: '';
|
|
168
|
+
if (code === 'EXDEV' || code === 'EPERM') {
|
|
169
|
+
await copyFile(from, to);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function recordOf(value) {
|
|
176
|
+
return typeof value === 'object' && value !== null
|
|
177
|
+
? Object.fromEntries(Object.entries(value))
|
|
178
|
+
: {};
|
|
179
|
+
}
|
|
180
|
+
function readAssets(manifest) {
|
|
181
|
+
if (!manifest || typeof manifest !== 'object' || !('assets' in manifest)) {
|
|
182
|
+
throw new Error('OpenStory answered without an asset list');
|
|
183
|
+
}
|
|
184
|
+
const assets = manifest.assets;
|
|
185
|
+
if (!Array.isArray(assets)) {
|
|
186
|
+
throw new Error('OpenStory answered without an asset list');
|
|
187
|
+
}
|
|
188
|
+
return assets.map((asset, index) => {
|
|
189
|
+
if (!asset || typeof asset !== 'object') {
|
|
190
|
+
throw new Error(`Asset ${index} is not an object`);
|
|
191
|
+
}
|
|
192
|
+
const row = recordOf(asset);
|
|
193
|
+
const relativePath = row.relativePath;
|
|
194
|
+
const versionId = row.versionId;
|
|
195
|
+
if (typeof relativePath !== 'string' || typeof versionId !== 'string') {
|
|
196
|
+
throw new Error(`Asset ${index} has no path`);
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
relativePath,
|
|
200
|
+
versionId,
|
|
201
|
+
url: typeof row.url === 'string' ? row.url : null,
|
|
202
|
+
text: typeof row.text === 'string' ? row.text : null,
|
|
203
|
+
shared: row.shared === true,
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
async function previousVersions(dest) {
|
|
208
|
+
let text;
|
|
209
|
+
try {
|
|
210
|
+
text = await readFile(join(dest, 'manifest.json'), 'utf8');
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (isMissing(error))
|
|
214
|
+
return new Map();
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
let parsed;
|
|
218
|
+
try {
|
|
219
|
+
parsed = JSON.parse(text);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return new Map();
|
|
223
|
+
}
|
|
224
|
+
const versions = new Map();
|
|
225
|
+
if (!parsed || typeof parsed !== 'object' || !('assets' in parsed)) {
|
|
226
|
+
return versions;
|
|
227
|
+
}
|
|
228
|
+
const assets = parsed.assets;
|
|
229
|
+
if (!Array.isArray(assets))
|
|
230
|
+
return versions;
|
|
231
|
+
for (const asset of assets) {
|
|
232
|
+
if (!asset || typeof asset !== 'object')
|
|
233
|
+
continue;
|
|
234
|
+
const path = 'relativePath' in asset ? asset.relativePath : null;
|
|
235
|
+
const versionId = 'versionId' in asset ? asset.versionId : null;
|
|
236
|
+
if (typeof path === 'string' && typeof versionId === 'string') {
|
|
237
|
+
versions.set(path, versionId);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return versions;
|
|
241
|
+
}
|
|
242
|
+
/** One cache file per stored url. A sheet and its portrait must not collide. */
|
|
243
|
+
function sharedCacheFile(cacheDir, asset) {
|
|
244
|
+
const ext = extname(asset.relativePath);
|
|
245
|
+
const identity = asset.url
|
|
246
|
+
? (asset.url.split('?')[0] ?? asset.url)
|
|
247
|
+
: `${asset.versionId}${ext}`;
|
|
248
|
+
const name = createHash('sha256').update(identity).digest('hex');
|
|
249
|
+
return join(cacheDir, `${name}${ext}`);
|
|
250
|
+
}
|
|
251
|
+
function safeRelativePath(path) {
|
|
252
|
+
if (path.startsWith('/') || path.includes('\\') || path.includes('\0')) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
return path
|
|
256
|
+
.split('/')
|
|
257
|
+
.every((part) => part !== '' && part !== '.' && part !== '..');
|
|
258
|
+
}
|
|
259
|
+
async function fileExists(dest, relativePath) {
|
|
260
|
+
try {
|
|
261
|
+
const info = await stat(join(dest, relativePath));
|
|
262
|
+
return info.isFile();
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
if (isMissing(error))
|
|
266
|
+
return false;
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function isMissing(error) {
|
|
271
|
+
return (typeof error === 'object' &&
|
|
272
|
+
error !== null &&
|
|
273
|
+
'code' in error &&
|
|
274
|
+
error.code === 'ENOENT');
|
|
275
|
+
}
|
|
276
|
+
async function errorText(response) {
|
|
277
|
+
try {
|
|
278
|
+
return (await response.text()).slice(0, 200);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return '';
|
|
282
|
+
}
|
|
283
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** `openstory`: the OpenStory account commands. stitch plays cuts; this talks to openstory.so. */
|
|
2
|
+
import type { Writer } from './context.js';
|
|
3
|
+
export declare const usage: string;
|
|
4
|
+
export declare function main(argv: string[], io?: {
|
|
5
|
+
cwd?: string;
|
|
6
|
+
stdout?: Writer;
|
|
7
|
+
stderr?: Writer;
|
|
8
|
+
home?: string;
|
|
9
|
+
open?: (url: string) => void;
|
|
10
|
+
fetch?: typeof fetch;
|
|
11
|
+
now?: () => number;
|
|
12
|
+
}): Promise<void>;
|
|
13
|
+
export declare function openUrl(url: string, stderr: Writer): void;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** `openstory`: the OpenStory account commands. stitch plays cuts; this talks to openstory.so. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { CLI_VERSION, versionedFetch } from './api.js';
|
|
5
|
+
import { login, moveOldCredentials } from './login.js';
|
|
6
|
+
import { pull } from './pull.js';
|
|
7
|
+
import { share } from './share.js';
|
|
8
|
+
import { sync } from './sync.js';
|
|
9
|
+
import { updateNotice } from './update-notice.js';
|
|
10
|
+
/** One line per subcommand; each lives in its own file. */
|
|
11
|
+
const COMMANDS = { login, pull, share, sync };
|
|
12
|
+
export const usage = `Usage: ${Object.values(COMMANDS)
|
|
13
|
+
.map((command) => command.usage)
|
|
14
|
+
.join('\n ')}
|
|
15
|
+
|
|
16
|
+
openstory login signs in with a device code: it prints a short code and
|
|
17
|
+
opens the approval page. The key is kept in ~/.openstory/credentials.json,
|
|
18
|
+
one per server. pull and share sign in the same way on first use.
|
|
19
|
+
|
|
20
|
+
openstory pull downloads a sequence into ./<sequenceId> (or --dir). A later
|
|
21
|
+
run skips a file that is already there at the same version. --history adds
|
|
22
|
+
every completed version. --exports adds rendered MP4s.
|
|
23
|
+
|
|
24
|
+
openstory share uploads a cut (the same arguments as stitch) to OpenStory
|
|
25
|
+
and prints a link anyone can open. Every still is checked for a real person;
|
|
26
|
+
one who is needs --portrait-rights with the basis for using their likeness
|
|
27
|
+
(a release reference, "self", a contract id).
|
|
28
|
+
|
|
29
|
+
openstory sync mirrors every sequence of the key's organization into
|
|
30
|
+
~/openstory/<organization>/<sequence>/ (or --dir), then keeps it current
|
|
31
|
+
until stopped. Your sign-up organization is personal/. The folder is output
|
|
32
|
+
only: OpenStory never reads it back, and a renamed sequence renames its
|
|
33
|
+
folder. --once stops after the first pass.
|
|
34
|
+
|
|
35
|
+
--origin picks the OpenStory server (default https://openstory.so).
|
|
36
|
+
Run it with npx @openstory/cli@latest <command>.
|
|
37
|
+
`;
|
|
38
|
+
export async function main(argv, io = {}) {
|
|
39
|
+
const stdout = io.stdout ?? process.stdout;
|
|
40
|
+
const stderr = io.stderr ?? process.stderr;
|
|
41
|
+
const home = io.home ?? homedir();
|
|
42
|
+
const rawFetch = io.fetch ?? fetch;
|
|
43
|
+
// Only a person at a terminal sees the hint; tests inject stderr.
|
|
44
|
+
const notice = { hint: null };
|
|
45
|
+
if (io.stderr === undefined && process.stderr.isTTY && !process.env.CI) {
|
|
46
|
+
void updateNotice({
|
|
47
|
+
home,
|
|
48
|
+
current: CLI_VERSION,
|
|
49
|
+
now: (io.now ?? Date.now)(),
|
|
50
|
+
fetch: rawFetch,
|
|
51
|
+
}).then((text) => {
|
|
52
|
+
notice.hint = text;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const [name, ...args] = argv;
|
|
57
|
+
if (name === '--version' || name === '-v') {
|
|
58
|
+
stdout.write(`${CLI_VERSION}\n`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (name === undefined || name === '--help' || name === '-h') {
|
|
62
|
+
stdout.write(usage);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const command = COMMANDS[name];
|
|
66
|
+
if (command === undefined) {
|
|
67
|
+
throw new Error(`Unknown command: ${name}. Run openstory --help`);
|
|
68
|
+
}
|
|
69
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
70
|
+
stdout.write(`Usage: ${command.usage}\n`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
await moveOldCredentials(home);
|
|
74
|
+
await command.run(args, {
|
|
75
|
+
cwd: io.cwd ?? process.cwd(),
|
|
76
|
+
home,
|
|
77
|
+
stdout,
|
|
78
|
+
stderr,
|
|
79
|
+
open: io.open ?? ((url) => openUrl(url, stderr)),
|
|
80
|
+
fetch: versionedFetch(rawFetch),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
stderr.write(`${error instanceof Error ? error.message : 'openstory failed'}\n`);
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
if (notice.hint !== null)
|
|
89
|
+
stderr.write(notice.hint);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function openUrl(url, stderr) {
|
|
93
|
+
// Windows goes through rundll32, not `cmd /c start`: cmd parses & and ^
|
|
94
|
+
// in a URL, and the sign-in URL comes from the server.
|
|
95
|
+
const command = process.platform === 'darwin'
|
|
96
|
+
? 'open'
|
|
97
|
+
: process.platform === 'win32'
|
|
98
|
+
? 'rundll32'
|
|
99
|
+
: 'xdg-open';
|
|
100
|
+
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
|
|
101
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
102
|
+
const failed = (reason) => stderr.write(`Could not open a browser (${reason}). Open ${url}\n`);
|
|
103
|
+
child.on('error', (error) => failed(error.message));
|
|
104
|
+
child.on('exit', (code) => {
|
|
105
|
+
if (code !== 0 && code !== null)
|
|
106
|
+
failed(`${command} exited ${code}`);
|
|
107
|
+
});
|
|
108
|
+
child.unref();
|
|
109
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** What every subcommand gets, and the flags they share. */
|
|
2
|
+
export type Writer = {
|
|
3
|
+
write: (text: string) => void;
|
|
4
|
+
};
|
|
5
|
+
export type CliContext = {
|
|
6
|
+
cwd: string;
|
|
7
|
+
home: string;
|
|
8
|
+
stdout: Writer;
|
|
9
|
+
stderr: Writer;
|
|
10
|
+
open: (url: string) => void;
|
|
11
|
+
/** Sends the version header and turns a 426 into an error; OpenStory only. */
|
|
12
|
+
fetch: typeof fetch;
|
|
13
|
+
};
|
|
14
|
+
/** A subcommand: one file under src/, one line in COMMANDS (cli.ts). */
|
|
15
|
+
export type Command = {
|
|
16
|
+
usage: string;
|
|
17
|
+
run: (args: string[], context: CliContext) => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
/** Takes `--origin url` out of the args; the rest are the command's own. */
|
|
20
|
+
export declare function parseOrigin(args: string[]): {
|
|
21
|
+
origin: string;
|
|
22
|
+
rest: string[];
|
|
23
|
+
};
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** What every subcommand gets, and the flags they share. */
|
|
2
|
+
import { isHttpUrl } from '@openstory/stitch/bundle';
|
|
3
|
+
const DEFAULT_ORIGIN = 'https://openstory.so';
|
|
4
|
+
/** Takes `--origin url` out of the args; the rest are the command's own. */
|
|
5
|
+
export function parseOrigin(args) {
|
|
6
|
+
let origin = DEFAULT_ORIGIN;
|
|
7
|
+
const rest = [];
|
|
8
|
+
for (let i = 0; i < args.length; i++) {
|
|
9
|
+
const arg = args[i] ?? '';
|
|
10
|
+
if (arg === '--origin') {
|
|
11
|
+
const value = args[i + 1];
|
|
12
|
+
if (value === undefined || !isHttpUrl(value)) {
|
|
13
|
+
throw new Error('--origin needs an http(s) URL');
|
|
14
|
+
}
|
|
15
|
+
origin = new URL(value).origin;
|
|
16
|
+
i += 1;
|
|
17
|
+
}
|
|
18
|
+
else
|
|
19
|
+
rest.push(arg);
|
|
20
|
+
}
|
|
21
|
+
return { origin, rest };
|
|
22
|
+
}
|
package/dist/login.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
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 type { CliContext, Command } from './context.js';
|
|
6
|
+
export type SignInOptions = Pick<CliContext, 'home' | 'stderr' | 'open' | 'fetch'> & {
|
|
7
|
+
origin: string;
|
|
8
|
+
};
|
|
9
|
+
export declare const login: Command;
|
|
10
|
+
/** Renames stitch's key file once; never over an existing credentials file. */
|
|
11
|
+
export declare function moveOldCredentials(home: string): Promise<void>;
|
|
12
|
+
/** The stored key, or a fresh device-code sign-in. */
|
|
13
|
+
export declare function openStoryApiKey(options: SignInOptions): Promise<string>;
|
|
14
|
+
export declare function storedKey(home: string, origin: string): Promise<string | null>;
|
|
15
|
+
export declare function isMissingFile(error: unknown): boolean;
|
|
16
|
+
/** Device-code sign-in (RFC 8628 shape); stores and returns the API key. */
|
|
17
|
+
export declare function signIn(options: SignInOptions): Promise<string>;
|