tosijs-ui 1.16.24 → 1.16.25

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/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.25
4
+
5
+ ### Dev server
6
+
7
+ - **`editableData`: folders an editor on the dev page may save to** (#3365). A level or
8
+ scene editor running on a dev-server page can list, read and save the project's data files
9
+ through `/__docstore/data`, behind the same gate as source: this machine, an edit session
10
+ over the tunnel, or the LAN under `trustLan`. Share links stay read-only.
11
+ `editableData: ['static/levels']`, or `{ dirs, extensions, commit }`. Only data formats may
12
+ be allowed (`.json` by default; also `.jsonc` `.json5` `.ndjson` `.txt` `.csv` `.tsv`
13
+ `.yaml` `.yml` `.toml`), so nothing saved there runs or renders. Dot-files and symlinks are
14
+ never written, nor are common configuration names (`package.json`, `tsconfig.json`,
15
+ `firebase.json`, `*.config.*`), though the folders you name are the real boundary. A `.json`
16
+ save must parse, and a save replaces the file whole. A folder outside the project, the
17
+ project itself, one in or around `.git` or `node_modules`, or one overlapping the build
18
+ output stops the server at launch. `tosijs-ui/site` exports the `EditableDataOptions` type
19
+ and the `DATA_EXTENSIONS` list.
20
+ - **`commit: true` makes every save a version.** Each save is its own commit
21
+ (`data: save <file>`, naming who saved it), and other staged work is left alone. The
22
+ repository's hooks run, and each git step gets 20 seconds. Nothing is committed while the
23
+ repository is mid-rebase, mid-merge, cherry-picking, reverting, bisecting or on a detached
24
+ HEAD. `?history=<file>` lists a file's latest 100 versions and `?file=<file>&at=<commit>` reads
25
+ one back. A save that wrote the file but did not commit answers with `commitError` instead of
26
+ reporting a clean save. Each committed save waits for git, so it suits an explicit Save, not
27
+ autosave.
28
+ - **`tosijs-ui/doc-system/data-client`** wraps the endpoint for an editor: `listData`,
29
+ `readData` (text, or `{ json: true }`, or `{ at }`), `saveData` (a string as is, anything
30
+ else as formatted JSON) and `dataHistory`. A refusal rejects with a `DataStoreError` carrying
31
+ the status, and so does a page answering for the endpoint (a static host's catch-all), so
32
+ `listData()` tells an editor whether to offer saving.
33
+ - **`trustLan` can name your network**, so a committed setting trusts nobody on anyone else's:
34
+ `trustLan: '192.168.1.0/24'` or a list, IPv4 or IPv6. Only private addresses inside it are
35
+ trusted. **`TOSIJS_TRUST_LAN`** sets it for one run and wins over the config either way: `1`,
36
+ a network, or `0` to turn a committed setting off. A network that cannot be parsed stops the
37
+ server at launch, and the launch warning names who is trusted and where the setting came from.
38
+ A refused device outside the named networks is told so in the log, and the messages readers
39
+ see when the LAN is refused now suggest these forms rather than `trustLan: true`.
40
+ - **The SPA fallback shows the page the URL names.** Was: a path with no file of its own got
41
+ the root page, which then asked for its assets relative to that path, got 404s and never
42
+ hydrated; with `home` set, a reader saw the home doc. Now the dev server serves the root page
43
+ with its relative URLs made root-relative (in-page `#` links still stay on the page), and
44
+ hydration adopts a pre-rendered article only when it is the doc being shown. Generated pages
45
+ stamp the article with `data-doc` for that. The asset fix is the dev server's; a static host's
46
+ own catch-all rewrite is unchanged.
47
+
3
48
  ## 1.16.24
4
49
 
5
50
  `dist/iife.js` grows by about 0.2 kB gzip.
@@ -111,6 +111,7 @@ import { tosiLocalized } from './localize.js';
111
111
  import { popMenu } from './menu.js';
112
112
  import { codeEditor } from './code-editor.js';
113
113
  import { tosiDiff } from './diff.js';
114
+ import { LAN_REFUSAL_ADVICE } from './live-example/source-access.js';
114
115
  /*
115
116
  Does this doc contain a REAL ` ```test ` block, as opposed to mentioning one?
116
117
 
@@ -954,8 +955,18 @@ export function createDocBrowser(options) {
954
955
  };
955
956
  let adoptInitialContent = contentElement !== undefined && !memoryRouting;
956
957
  const showDoc = (doc) => {
957
- if (adoptInitialContent) {
958
- adoptInitialContent = false; // leave the pre-rendered HTML untouched
958
+ /*
959
+ Adopt the pre-render only if it IS this doc. The dev server's SPA fallback serves the root
960
+ page for a path with no file of its own, so `/guide/` can arrive holding the home page's
961
+ article (#3346). The generator stamps `data-doc`; a page without it predates the stamp
962
+ and is adopted as before.
963
+ */
964
+ const prerendered = contentElement?.dataset.doc;
965
+ const adopt = adoptInitialContent &&
966
+ (prerendered === undefined || prerendered === doc.filename);
967
+ adoptInitialContent = false;
968
+ if (adopt) {
969
+ // leave the pre-rendered HTML untouched
959
970
  }
960
971
  else {
961
972
  // Pass the doc's build-time tjs bakes so this client-rendered page embeds the
@@ -1291,7 +1302,7 @@ export function createDocBrowser(options) {
1291
1302
  window.alert('This workspace needs an invite link before it can save.\n\n' +
1292
1303
  'Run `tosijs-tunnel --link` on the machine hosting it, open the link once, ' +
1293
1304
  'then try again. Your edit is still here.\n\n' +
1294
- 'If you opened this over the LAN (a `.local` or local IP address), invite links will not help: set `trustLan: true` in the site config, or use its tunnel URL instead.');
1305
+ LAN_REFUSAL_ADVICE);
1295
1306
  }
1296
1307
  else {
1297
1308
  // Genuinely no write endpoint (a deployed static site) — hand the file back.
@@ -1306,7 +1317,7 @@ export function createDocBrowser(options) {
1306
1317
  window.alert(sourceUnauthorized
1307
1318
  ? 'This workspace needs an invite link before you can edit.\n\n' +
1308
1319
  'Run `tosijs-tunnel --link` on the machine hosting it and open the link once.\n\n' +
1309
- 'If you opened this over the LAN (a `.local` or local IP address), invite links will not help: set `trustLan: true` in the site config, or use its tunnel URL instead.'
1320
+ LAN_REFUSAL_ADVICE
1310
1321
  : `Could not load source for ${doc.path}`);
1311
1322
  return;
1312
1323
  }
@@ -0,0 +1,35 @@
1
+ export declare class DataStoreError extends Error {
2
+ status: number;
3
+ constructor(message: string, status: number);
4
+ }
5
+ /** Every editable file, project-relative, newest first. */
6
+ export declare function listData(): Promise<{
7
+ files: {
8
+ file: string;
9
+ size: number;
10
+ modified: string;
11
+ }[];
12
+ }>;
13
+ /** A file's text, or parsed JSON with `json: true`; `at` reads it as of a commit. */
14
+ export declare function readData(file: string, options: {
15
+ at?: string;
16
+ json: true;
17
+ }): Promise<any>;
18
+ export declare function readData(file: string, options?: {
19
+ at?: string;
20
+ json?: false;
21
+ }): Promise<string>;
22
+ /** Save a file: a string as it is, anything else as formatted JSON. */
23
+ export declare function saveData(file: string, content: unknown): Promise<{
24
+ ok: true;
25
+ file: string;
26
+ commit?: string | null;
27
+ commitError?: string;
28
+ }>;
29
+ /** A file's committed versions, newest first. */
30
+ export declare function dataHistory(file: string): Promise<{
31
+ commit: string;
32
+ date: string;
33
+ author: string;
34
+ subject: string;
35
+ }[]>;
@@ -0,0 +1,105 @@
1
+ /*{ "parent": "Documentation System", "order": 85 }*/
2
+ /*#
3
+ # data client
4
+
5
+ For an editor running on a dev-server page (a level editor, a scene editor) to open and save
6
+ the project's data files through `/__docstore/data`. Turn it on by naming the folders in the
7
+ site config; [`editableData`](/doc-site-system/) in the doc-site system reference has the
8
+ rules.
9
+
10
+ ```typescript
11
+ // tosijs-site.config.ts
12
+ editableData: { dirs: ['static/levels'], commit: true }
13
+ ```
14
+
15
+ ```typescript
16
+ import { listData, readData, saveData, dataHistory } from 'tosijs-ui/doc-system/data-client'
17
+
18
+ const { files } = await listData()
19
+ const level = await readData('static/levels/ocean.json', { json: true })
20
+ level.spawn = [0, 2, 0]
21
+ const { commit } = await saveData('static/levels/ocean.json', level)
22
+ const versions = await dataHistory('static/levels/ocean.json')
23
+ if (versions.length > 1) {
24
+ const before = await readData('static/levels/ocean.json', { at: versions[1].commit })
25
+ }
26
+ ```
27
+
28
+ `saveData` writes an object as JSON (two-space indent, trailing newline) and a string as it
29
+ is. With `commit: true` in the config each save is its own commit, and `commit` in the result
30
+ is its hash (`null` when the save changed nothing). A save that wrote but could not commit
31
+ (a failing hook, or a repository mid-rebase) resolves with `commitError` set rather than
32
+ rejecting, because the file was saved. Each committed save runs git and the repository's
33
+ hooks before it answers, so it suits an explicit Save, not autosave.
34
+
35
+ **Dev server only.** Everything else rejects with a `DataStoreError` whose `status` says why:
36
+ `501` when the server has no `editableData`, `403` when this page may not edit files here or
37
+ the file is not editable, `400` for content it refuses (JSON that does not parse). A response
38
+ that is not the endpoint's (a deployed site's catch-all answering with a page) rejects too, so
39
+ `listData()` is the probe for whether to offer saving at all.
40
+
41
+ File names may use letters, digits, `-`, `_`, `.`, `/` and spaces. `dataHistory` returns the
42
+ latest 100 versions.
43
+ */
44
+ const ENDPOINT = '/__docstore/data';
45
+ export class DataStoreError extends Error {
46
+ status;
47
+ constructor(message, status) {
48
+ super(message);
49
+ this.status = status;
50
+ this.name = 'DataStoreError';
51
+ }
52
+ }
53
+ async function ask(query, init) {
54
+ const response = await fetch(`${ENDPOINT}${query}`, {
55
+ credentials: 'same-origin',
56
+ ...init,
57
+ });
58
+ /*
59
+ The endpoint answers JSON or the file's text, never a page. An HTML answer is something
60
+ else's: a static host's catch-all serving its index for every path.
61
+ */
62
+ const type = response.headers.get('content-type') ?? '';
63
+ if (response.ok && type.includes('text/html')) {
64
+ throw new DataStoreError(`${init?.method ?? 'GET'} ${ENDPOINT}${query}: answered with a page, not the data endpoint (no dev server here?)`, response.status);
65
+ }
66
+ if (!response.ok) {
67
+ const text = await response.text();
68
+ let message = text;
69
+ try {
70
+ message = JSON.parse(text).error ?? text;
71
+ }
72
+ catch {
73
+ // plain text
74
+ }
75
+ throw new DataStoreError(`${init?.method ?? 'GET'} ${ENDPOINT}${query}: ${response.status} ${message}`, response.status);
76
+ }
77
+ return response;
78
+ }
79
+ const q = (params) => '?' + new URLSearchParams(params).toString();
80
+ /** Every editable file, project-relative, newest first. */
81
+ export async function listData() {
82
+ return (await ask('?list')).json();
83
+ }
84
+ export async function readData(file, options = {}) {
85
+ const params = { file };
86
+ if (options.at)
87
+ params.at = options.at;
88
+ const text = await (await ask(q(params))).text();
89
+ return options.json ? JSON.parse(text) : text;
90
+ }
91
+ /** Save a file: a string as it is, anything else as formatted JSON. */
92
+ export async function saveData(file, content) {
93
+ const text = typeof content === 'string'
94
+ ? content
95
+ : JSON.stringify(content, null, 2) + '\n';
96
+ return (await ask('', {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify({ file, content: text }),
100
+ })).json();
101
+ }
102
+ /** A file's committed versions, newest first. */
103
+ export async function dataHistory(file) {
104
+ return (await (await ask(q({ history: file }))).json()).versions;
105
+ }
@@ -0,0 +1,71 @@
1
+ /** Formats a data folder may hold. Text only: the endpoint takes and returns strings. */
2
+ export declare const DATA_EXTENSIONS: readonly [".json", ".jsonc", ".json5", ".ndjson", ".txt", ".csv", ".tsv", ".yaml", ".yml", ".toml"];
3
+ /** The largest save accepted, in bytes. A level is kilobytes; this stops a runaway client. */
4
+ export declare const MAX_DATA_BYTES: number;
5
+ export interface EditableDataOptions {
6
+ /** Folders, relative to the project root. */
7
+ dirs: string[];
8
+ /** Extensions to allow, from DATA_EXTENSIONS. Default `['.json']`. */
9
+ extensions?: string[];
10
+ /** Commit each save to git, so every save is a version. Default false. */
11
+ commit?: boolean;
12
+ }
13
+ export interface DataStore {
14
+ root: string;
15
+ /** Real paths of the folders, and the names they were configured as. */
16
+ dirs: {
17
+ real: string;
18
+ name: string;
19
+ }[];
20
+ extensions: string[];
21
+ commit: boolean;
22
+ }
23
+ /** The folder setting, checked and resolved, or null when the project has none. Throws. */
24
+ export declare function resolveDataStore(setting: string[] | EditableDataOptions | undefined, root: string,
25
+ /** The site's build output, which every build deletes and rewrites. */
26
+ outputDir?: string): DataStore | null;
27
+ /**
28
+ * The absolute path a client's file name may be read or written at, or the reason it may not.
29
+ * `file` is project-relative (`static/levels/ocean.json`).
30
+ */
31
+ export declare function resolveDataFile(store: DataStore, file: unknown): {
32
+ path: string;
33
+ rel: string;
34
+ } | {
35
+ refused: string;
36
+ };
37
+ /** Why `content` may not be saved as `rel`, or null. JSON must parse: a half-edit is not a level. */
38
+ export declare function dataContentProblem(rel: string, content: unknown): string | null;
39
+ /** Write through a temporary file and a rename, so a reader never sees half a file. */
40
+ export declare function writeDataFile(target: string, content: string): void;
41
+ /** Every editable file in the named folders, project-relative, newest first. */
42
+ export declare function listDataFiles(store: DataStore): {
43
+ file: string;
44
+ size: number;
45
+ modified: string;
46
+ }[];
47
+ /** Whether `root` is in a git work tree, for `commit: true` to be checked at launch. */
48
+ export declare function isGitWorkTree(root: string): boolean;
49
+ /**
50
+ * Save a file. With `commit` on, also commit it, alone, leaving other staged work as it was:
51
+ * `commit` is the hash, or null when the save changed nothing. A save that wrote the file but
52
+ * could not commit it resolves with `commitError`, because the file WAS saved. The repository's
53
+ * hooks run, as for any commit; a repository mid-rebase, mid-merge, bisecting or on a detached
54
+ * HEAD is not committed to, since that commit would land somewhere the developer did not choose.
55
+ */
56
+ export declare function saveDataFile(store: DataStore, target: {
57
+ path: string;
58
+ rel: string;
59
+ }, content: string, savedBy: string): Promise<{
60
+ commit?: string | null;
61
+ commitError?: string;
62
+ }>;
63
+ /** One file's committed versions, newest first. */
64
+ export declare function dataHistory(store: DataStore, rel: string, limit?: number): Promise<{
65
+ commit: string;
66
+ date: string;
67
+ author: string;
68
+ subject: string;
69
+ }[]>;
70
+ /** A file as it was at `commit`, or null if the commit is not a hash or lacks the file. */
71
+ export declare function readDataAt(store: DataStore, rel: string, commit: string): Promise<string | null>;
@@ -0,0 +1,352 @@
1
+ /*
2
+ WHICH files `editableData` may touch, and how a save becomes a version (#3365).
3
+
4
+ `/__docstore/source` writes only scraped doc sources (#128), so an editor running on the dev
5
+ page (a level editor, say) could not save the JSON it edits. This is the sibling for data: a
6
+ list of folders the project names, behind the same gate (`sourceRequestAdmission`).
7
+
8
+ An allow-list at every level, as #128 argues for source:
9
+
10
+ - Folders: inside the project, never the project itself, never inside or containing `.git` or
11
+ `node_modules`. Resolved through symlinks, at launch.
12
+ - Extensions: data formats only, from DATA_EXTENSIONS. Nothing a browser or a tool executes or
13
+ renders (`.js`, `.html`, `.svg`, `.md`, whose fences run as live examples). A config naming
14
+ anything else stops the server rather than being trimmed quietly.
15
+ - Names: no dot-files and no names tools read as configuration (`package.json`,
16
+ `tsconfig.json`, `*.config.*`), so a folder that happens to hold one cannot be used to write it.
17
+ - Paths: plain characters only, no `..`, and the real path (symlinks resolved) must still be
18
+ inside a named folder. An existing symlink is never written through.
19
+
20
+ FAILS CLOSED: anything unexpected is a refusal with a reason, never a best effort.
21
+ */
22
+ import * as fs from 'fs';
23
+ import * as path from 'path';
24
+ /** Formats a data folder may hold. Text only: the endpoint takes and returns strings. */
25
+ export const DATA_EXTENSIONS = [
26
+ '.json',
27
+ '.jsonc',
28
+ '.json5',
29
+ '.ndjson',
30
+ '.txt',
31
+ '.csv',
32
+ '.tsv',
33
+ '.yaml',
34
+ '.yml',
35
+ '.toml',
36
+ ];
37
+ /** The largest save accepted, in bytes. A level is kilobytes; this stops a runaway client. */
38
+ export const MAX_DATA_BYTES = 16 * 1024 * 1024;
39
+ /*
40
+ Names tools read as configuration, some of which run commands. A backstop, not the boundary:
41
+ no list of every tool's file can be complete, so the folders a project names are what keep
42
+ writes away from configuration, and the docs say so.
43
+ */
44
+ const CONFIG_NAMES = [
45
+ /^package(-lock)?\.json$/,
46
+ /^[tj]sconfig(\..*)?\.json$/,
47
+ /^bunfig\.toml$/,
48
+ /^deno\.jsonc?$/,
49
+ /\.config\.[^.]+$/,
50
+ /^(project|turbo|nx|lerna|composer|firebase|vercel|now)\.json$/,
51
+ /^(Cargo|pyproject|netlify|wrangler|fly|Pipfile)\.toml$/i,
52
+ ];
53
+ /** The folder setting, checked and resolved, or null when the project has none. Throws. */
54
+ export function resolveDataStore(setting, root,
55
+ /** The site's build output, which every build deletes and rewrites. */
56
+ outputDir) {
57
+ if (setting == null)
58
+ return null;
59
+ const options = Array.isArray(setting)
60
+ ? { dirs: setting }
61
+ : setting;
62
+ const fail = (why) => {
63
+ throw new Error(`editableData: ${why}`);
64
+ };
65
+ if (!Array.isArray(options.dirs) || options.dirs.length === 0)
66
+ fail('name at least one folder, e.g. editableData: ["static/levels"]');
67
+ const extensions = (options.extensions ?? ['.json']).map((e) => (e.startsWith('.') ? e : '.' + e).toLowerCase());
68
+ for (const ext of extensions) {
69
+ if (!DATA_EXTENSIONS.includes(ext))
70
+ fail(`${JSON.stringify(ext)} is not a data format this endpoint will write. ` +
71
+ `Allowed: ${DATA_EXTENSIONS.join(' ')}`);
72
+ }
73
+ const realRoot = fs.realpathSync(root);
74
+ const output = outputDir ? path.resolve(realRoot, outputDir) : null;
75
+ const dirs = options.dirs.map((name) => {
76
+ if (typeof name !== 'string' || path.isAbsolute(name))
77
+ fail(`${JSON.stringify(name)} must be a folder relative to the project`);
78
+ let real;
79
+ try {
80
+ real = fs.realpathSync(path.resolve(realRoot, name));
81
+ }
82
+ catch {
83
+ return fail(`${JSON.stringify(name)} does not exist`);
84
+ }
85
+ if (!fs.statSync(real).isDirectory())
86
+ fail(`${JSON.stringify(name)} is not a folder`);
87
+ if (!real.startsWith(realRoot + path.sep))
88
+ fail(`${JSON.stringify(name)} must be inside the project, and not the project itself`);
89
+ const parts = path.relative(realRoot, real).split(path.sep);
90
+ if (parts.some((p) => p === '.git' || p === 'node_modules'))
91
+ fail(`${JSON.stringify(name)} is inside .git or node_modules`);
92
+ for (const inner of ['.git', 'node_modules'])
93
+ if (fs.existsSync(path.join(real, inner)))
94
+ fail(`${JSON.stringify(name)} contains ${inner}`);
95
+ if (output &&
96
+ (real === output ||
97
+ real.startsWith(output + path.sep) ||
98
+ output.startsWith(real + path.sep)))
99
+ fail(`${JSON.stringify(name)} overlaps the build output (${path.relative(realRoot, output)}), which every build deletes`);
100
+ return { real, name: name.replace(/\/+$/, '') };
101
+ });
102
+ return {
103
+ root: realRoot,
104
+ dirs,
105
+ extensions,
106
+ commit: options.commit === true,
107
+ };
108
+ }
109
+ /**
110
+ * The absolute path a client's file name may be read or written at, or the reason it may not.
111
+ * `file` is project-relative (`static/levels/ocean.json`).
112
+ */
113
+ export function resolveDataFile(store, file) {
114
+ if (typeof file !== 'string' || file === '')
115
+ return { refused: 'name a file' };
116
+ const rel = file.replace(/^\.?\/+/, '');
117
+ if (!/^[\w\- ./]+$/.test(rel))
118
+ return { refused: 'file names may use letters, digits, - _ . / and spaces' };
119
+ const parts = rel.split('/');
120
+ if (parts.some((p) => p === '' || p === '.' || p === '..'))
121
+ return { refused: 'no empty, . or .. path segments' };
122
+ if (parts.some((p) => p.startsWith('.')))
123
+ return { refused: 'no dot-files or dot-folders' };
124
+ const base = parts[parts.length - 1];
125
+ if (CONFIG_NAMES.some((re) => re.test(base)))
126
+ return { refused: `${base} is a name tools read as configuration` };
127
+ if (!store.extensions.includes(path.extname(base).toLowerCase()))
128
+ return {
129
+ refused: `only ${store.extensions.join(' ')} files are editable here`,
130
+ };
131
+ const target = path.resolve(store.root, rel);
132
+ let realParent;
133
+ try {
134
+ realParent = fs.realpathSync(path.dirname(target));
135
+ }
136
+ catch {
137
+ return { refused: 'that folder does not exist' };
138
+ }
139
+ const dir = store.dirs.find((d) => realParent === d.real || realParent.startsWith(d.real + path.sep));
140
+ if (!dir)
141
+ return { refused: 'not inside an editableData folder' };
142
+ const real = path.join(realParent, base);
143
+ try {
144
+ if (fs.lstatSync(real).isSymbolicLink())
145
+ return { refused: 'a symlink is never written through' };
146
+ }
147
+ catch {
148
+ // Does not exist yet: a new file in a named folder.
149
+ }
150
+ return { path: real, rel: path.relative(store.root, real) };
151
+ }
152
+ /** Why `content` may not be saved as `rel`, or null. JSON must parse: a half-edit is not a level. */
153
+ export function dataContentProblem(rel, content) {
154
+ if (typeof content !== 'string')
155
+ return 'content must be a string';
156
+ if (Buffer.byteLength(content) > MAX_DATA_BYTES)
157
+ return `content is over ${MAX_DATA_BYTES / 1024 / 1024} MB`;
158
+ if (path.extname(rel).toLowerCase() === '.json') {
159
+ try {
160
+ JSON.parse(content);
161
+ }
162
+ catch (e) {
163
+ return `not valid JSON: ${e.message}`;
164
+ }
165
+ }
166
+ return null;
167
+ }
168
+ /** Write through a temporary file and a rename, so a reader never sees half a file. */
169
+ export function writeDataFile(target, content) {
170
+ const temp = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`);
171
+ fs.writeFileSync(temp, content);
172
+ try {
173
+ fs.renameSync(temp, target);
174
+ }
175
+ catch (e) {
176
+ fs.rmSync(temp, { force: true });
177
+ throw e;
178
+ }
179
+ }
180
+ /** Every editable file in the named folders, project-relative, newest first. */
181
+ export function listDataFiles(store) {
182
+ const found = [];
183
+ const walk = (dir) => {
184
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
185
+ if (entry.name.startsWith('.') || entry.isSymbolicLink())
186
+ continue;
187
+ const full = path.join(dir, entry.name);
188
+ if (entry.isDirectory())
189
+ walk(full);
190
+ else if (entry.isFile()) {
191
+ const rel = path.relative(store.root, full).split(path.sep).join('/');
192
+ if ('path' in resolveDataFile(store, rel)) {
193
+ const stat = fs.statSync(full);
194
+ found.push({
195
+ file: rel,
196
+ size: stat.size,
197
+ modified: stat.mtime.toISOString(),
198
+ ms: stat.mtimeMs,
199
+ });
200
+ }
201
+ }
202
+ }
203
+ };
204
+ for (const dir of store.dirs)
205
+ walk(dir.real);
206
+ return found.sort((a, b) => b.ms - a.ms).map(({ ms: _ms, ...f }) => f);
207
+ }
208
+ /** Run git in the project and return stdout, or throw with its stderr. Bounded in time. */
209
+ async function git(root, args, timeoutMs = 20_000) {
210
+ const proc = Bun.spawn(['git', ...args], {
211
+ cwd: root,
212
+ stdout: 'pipe',
213
+ stderr: 'pipe',
214
+ stdin: 'ignore',
215
+ // A signing prompt or an editor must fail, not wait for a terminal nobody is watching.
216
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_EDITOR: 'false' },
217
+ });
218
+ let timedOut = false;
219
+ const timer = setTimeout(() => {
220
+ timedOut = true;
221
+ proc.kill();
222
+ }, timeoutMs);
223
+ const [out, err, code] = await Promise.all([
224
+ new Response(proc.stdout).text(),
225
+ new Response(proc.stderr).text(),
226
+ proc.exited,
227
+ ]);
228
+ clearTimeout(timer);
229
+ if (timedOut)
230
+ throw new Error(`git ${args[0]} took over ${timeoutMs / 1000}s (a slow hook, or a prompt) and was stopped`);
231
+ if (code !== 0)
232
+ throw new Error((err.trim() || out.trim() || `git ${args[0]} exited ${code}`).slice(0, 500));
233
+ return out;
234
+ }
235
+ /** Whether `root` is in a git work tree, for `commit: true` to be checked at launch. */
236
+ export function isGitWorkTree(root) {
237
+ const result = Bun.spawnSync(['git', 'rev-parse', '--is-inside-work-tree'], {
238
+ cwd: root,
239
+ stdout: 'pipe',
240
+ stderr: 'pipe',
241
+ });
242
+ return result.exitCode === 0 && result.stdout.toString().trim() === 'true';
243
+ }
244
+ /** Why the repository is in no state to take a commit, or null. */
245
+ async function repositoryBusy(root) {
246
+ const states = [
247
+ ['rebase-merge', 'in the middle of a rebase'],
248
+ ['rebase-apply', 'in the middle of a rebase'],
249
+ ['MERGE_HEAD', 'in the middle of a merge'],
250
+ ['CHERRY_PICK_HEAD', 'in the middle of a cherry-pick'],
251
+ ['REVERT_HEAD', 'in the middle of a revert'],
252
+ ['BISECT_LOG', 'in the middle of a bisect'],
253
+ ];
254
+ for (const [name, state] of states) {
255
+ const where = (await git(root, ['rev-parse', '--git-path', name])).trim();
256
+ if (fs.existsSync(path.resolve(root, where)))
257
+ return state;
258
+ }
259
+ try {
260
+ await git(root, ['symbolic-ref', '-q', 'HEAD']);
261
+ }
262
+ catch {
263
+ return 'on a detached HEAD';
264
+ }
265
+ return null;
266
+ }
267
+ async function commitFile(store, rel, savedBy) {
268
+ await git(store.root, ['add', '--', rel]);
269
+ const changed = await git(store.root, [
270
+ 'diff',
271
+ '--cached',
272
+ '--name-only',
273
+ '--',
274
+ rel,
275
+ ]);
276
+ if (!changed.trim())
277
+ return null;
278
+ await git(store.root, [
279
+ 'commit',
280
+ '-m',
281
+ `data: save ${rel}`,
282
+ '-m',
283
+ `Saved through the dev server by ${savedBy}.`,
284
+ '--',
285
+ rel,
286
+ ]);
287
+ return (await git(store.root, ['rev-parse', 'HEAD'])).trim();
288
+ }
289
+ // With `commit` on, saves run one at a time, each writing and then committing: two commits
290
+ // fight over .git/index.lock, and a write that lands between another save's write and its
291
+ // `git add` would put one save's content under the other's name (1.16.25 review).
292
+ let saveQueue = Promise.resolve();
293
+ /**
294
+ * Save a file. With `commit` on, also commit it, alone, leaving other staged work as it was:
295
+ * `commit` is the hash, or null when the save changed nothing. A save that wrote the file but
296
+ * could not commit it resolves with `commitError`, because the file WAS saved. The repository's
297
+ * hooks run, as for any commit; a repository mid-rebase, mid-merge, bisecting or on a detached
298
+ * HEAD is not committed to, since that commit would land somewhere the developer did not choose.
299
+ */
300
+ export function saveDataFile(store, target, content, savedBy) {
301
+ if (!store.commit) {
302
+ writeDataFile(target.path, content);
303
+ return Promise.resolve({});
304
+ }
305
+ const run = async () => {
306
+ writeDataFile(target.path, content);
307
+ try {
308
+ const busy = await repositoryBusy(store.root);
309
+ if (busy)
310
+ return {
311
+ commit: null,
312
+ commitError: `not committed: the repository is ${busy}`,
313
+ };
314
+ return { commit: await commitFile(store, target.rel, savedBy) };
315
+ }
316
+ catch (e) {
317
+ return { commit: null, commitError: e.message };
318
+ }
319
+ };
320
+ const result = saveQueue.then(run, run);
321
+ saveQueue = result.catch(() => undefined);
322
+ return result;
323
+ }
324
+ /** One file's committed versions, newest first. */
325
+ export async function dataHistory(store, rel, limit = 100) {
326
+ const out = await git(store.root, [
327
+ 'log',
328
+ `-n${limit}`,
329
+ '--format=%H%x1f%aI%x1f%an%x1f%s',
330
+ '--',
331
+ rel,
332
+ ]);
333
+ return out
334
+ .split('\n')
335
+ .filter(Boolean)
336
+ .map((line) => {
337
+ const [commit, date, author, subject] = line.split('\x1f');
338
+ return { commit, date, author, subject };
339
+ });
340
+ }
341
+ /** A file as it was at `commit`, or null if the commit is not a hash or lacks the file. */
342
+ export async function readDataAt(store, rel, commit) {
343
+ if (!/^[0-9a-f]{4,40}$/i.test(commit))
344
+ return null;
345
+ try {
346
+ // `./` makes the path relative to the project, which need not be the repository's root.
347
+ return await git(store.root, ['show', `${commit}:./${rel}`]);
348
+ }
349
+ catch {
350
+ return null;
351
+ }
352
+ }