@mohou/host 1.0.20
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 +10 -0
- package/package.json +65 -0
- package/src/apps/monogram.ts +15 -0
- package/src/apps/registry.ts +238 -0
- package/src/apps/startup-seed.ts +95 -0
- package/src/bash/client.ts +21 -0
- package/src/bash/codes.ts +21 -0
- package/src/compile/allowlist.ts +89 -0
- package/src/compile/asset-path.ts +48 -0
- package/src/compile/autogen.ts +71 -0
- package/src/compile/build-vendor-cli.ts +8 -0
- package/src/compile/build-vendor.ts +160 -0
- package/src/compile/bundle-ui.ts +137 -0
- package/src/compile/codes.ts +22 -0
- package/src/compile/failure.ts +38 -0
- package/src/compile/imports.ts +43 -0
- package/src/compile/leave.ts +47 -0
- package/src/compile/load-backend.ts +95 -0
- package/src/compile/review.ts +342 -0
- package/src/compile/runner.ts +205 -0
- package/src/compile/sheet.ts +174 -0
- package/src/compile/wrapper.ts +297 -0
- package/src/credentials/codes.ts +26 -0
- package/src/credentials/create.ts +69 -0
- package/src/credentials/file.ts +113 -0
- package/src/credentials/provider.ts +66 -0
- package/src/events/app-events.ts +214 -0
- package/src/events/error-ring.ts +109 -0
- package/src/events/host-events.ts +39 -0
- package/src/events/view-bridge.ts +402 -0
- package/src/events/view-eval.ts +213 -0
- package/src/files/codes.ts +28 -0
- package/src/files/skip.ts +23 -0
- package/src/files/tools.ts +215 -0
- package/src/history/codes.ts +22 -0
- package/src/history/store.ts +287 -0
- package/src/host/activity.ts +110 -0
- package/src/host/author-mcp.ts +248 -0
- package/src/host/author-skill.ts +196 -0
- package/src/host/codes.ts +21 -0
- package/src/host/config.ts +239 -0
- package/src/host/heat.ts +35 -0
- package/src/host/layout.ts +81 -0
- package/src/host/log.ts +142 -0
- package/src/host/mcp-editor.ts +205 -0
- package/src/host/mcp-import.ts +141 -0
- package/src/host/mcp.ts +109 -0
- package/src/host/port.ts +83 -0
- package/src/host/session.ts +358 -0
- package/src/host/workbench.ts +38 -0
- package/src/http/app.ts +46 -0
- package/src/http/asset.ts +49 -0
- package/src/http/author.ts +157 -0
- package/src/http/bind.ts +217 -0
- package/src/http/client.ts +175 -0
- package/src/http/codes.ts +29 -0
- package/src/http/env.ts +3 -0
- package/src/http/guard.ts +20 -0
- package/src/http/iframe.ts +212 -0
- package/src/http/layout.ts +63 -0
- package/src/http/owner.ts +380 -0
- package/src/http/ports.ts +114 -0
- package/src/http/reply.ts +53 -0
- package/src/http/route-codes.ts +19 -0
- package/src/http/updates.ts +258 -0
- package/src/index.ts +201 -0
- package/src/install/codes.ts +21 -0
- package/src/install/install.ts +230 -0
- package/src/install/layout.ts +16 -0
- package/src/kernel/bind-brain.ts +117 -0
- package/src/kernel/call.ts +134 -0
- package/src/kernel/codes.ts +21 -0
- package/src/kernel/index.ts +13 -0
- package/src/kernel/model-policy.ts +83 -0
- package/src/kernel/model-stream.ts +86 -0
- package/src/kernel/present.ts +4 -0
- package/src/kernel/working-directory.ts +35 -0
- package/src/metrics/codes.ts +22 -0
- package/src/metrics/read.ts +48 -0
- package/src/owner/read.ts +85 -0
- package/src/pwsh/client.ts +47 -0
- package/src/pwsh/codes.ts +21 -0
- package/src/shell/command.ts +172 -0
- package/src/storage/codes.ts +33 -0
- package/src/storage/layout.ts +44 -0
- package/src/storage/open.ts +461 -0
- package/src/storage/schema.ts +176 -0
- package/src/storage/sql.ts +40 -0
- package/src/theme/codes.ts +20 -0
- package/src/theme/layout.ts +42 -0
- package/src/theme/paint.ts +108 -0
- package/src/theme/parse.ts +58 -0
- package/src/theme/pin.ts +197 -0
- package/src/theme/tokens.ts +30 -0
- package/src/tools/author.ts +970 -0
- package/src/tools/codes.ts +21 -0
- package/src/tools/diagnostics.ts +53 -0
- package/src/tools/http.ts +48 -0
- package/src/tools/mcp-list.ts +90 -0
- package/src/tools/schemas.ts +185 -0
- package/themes/theme-default.css +4 -0
- package/themes/theme-forest.css +4 -0
- package/themes/theme-hokage.css +4 -0
- package/themes/theme-matcha.css +4 -0
- package/themes/theme-slate.css +4 -0
- package/themes/theme-tokyo.css +4 -0
- package/themes/theme-yellow.css +4 -0
- package/themes/theme-zoro.css +4 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { appEntries } from '@mohou/contract'
|
|
5
|
+
|
|
6
|
+
import { FileToolError } from './codes.ts'
|
|
7
|
+
import { snapshotSkip } from './skip.ts'
|
|
8
|
+
|
|
9
|
+
/** Names listing never returns. The set is `snapshotSkip`. */
|
|
10
|
+
const SKIP = snapshotSkip
|
|
11
|
+
|
|
12
|
+
/** Required entries. One table, not a special case for the manifest. */
|
|
13
|
+
const PROTECTED = new Set<string>([appEntries.manifest, appEntries.ui, appEntries.backend])
|
|
14
|
+
|
|
15
|
+
export interface FileRead {
|
|
16
|
+
path: string
|
|
17
|
+
content: string
|
|
18
|
+
bytes: number
|
|
19
|
+
totalLines: number
|
|
20
|
+
startLine: number
|
|
21
|
+
endLine: number
|
|
22
|
+
truncated?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FileCommit {
|
|
26
|
+
status: 'committed' | 'clean' | 'skipped' | 'failed'
|
|
27
|
+
reason?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Authoring file tools for one app directory. Paths use `/` on every platform.
|
|
32
|
+
* @param appDir - absolute app directory
|
|
33
|
+
* @param options - window cap and the history commit, which is not wired here
|
|
34
|
+
*/
|
|
35
|
+
export function createFileTools(appDir: string, options?: {
|
|
36
|
+
readonly maxLines?: number
|
|
37
|
+
readonly commit?: (message: string) => Promise<FileCommit>
|
|
38
|
+
/** Another owner decides. This module does not know why a path is kept. */
|
|
39
|
+
readonly protectedPath?: (portablePath: string) => boolean
|
|
40
|
+
}) {
|
|
41
|
+
return {
|
|
42
|
+
list: () => listFiles(appDir),
|
|
43
|
+
read: (relative: string, window?: { start?: number; end?: number }, numbered = false) =>
|
|
44
|
+
readAppFile(appDir, relative, window, numbered, options?.maxLines),
|
|
45
|
+
edit: (relative: string, edits: Array<{ oldText: string; newText: string }>, commit = true) =>
|
|
46
|
+
editAppFile(appDir, relative, edits, commit, options?.commit, options?.protectedPath),
|
|
47
|
+
write: (relative: string, content: string, commit = true) =>
|
|
48
|
+
writeAppFile(appDir, relative, content, commit, options?.commit, options?.protectedPath),
|
|
49
|
+
delete: (relative: string, commit = true) =>
|
|
50
|
+
deleteAppFile(appDir, relative, commit, options?.commit, options?.protectedPath),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Reject an absolute path or `..` on both POSIX and Windows, whatever the host is. */
|
|
55
|
+
export function resolveAppPath(appDir: string, relative: string): string {
|
|
56
|
+
if (relative === '' || isAbsolute(relative) || relative.split(/[\\/]/).includes('..')) {
|
|
57
|
+
throw new FileToolError('path-escape', `path escapes the app directory: ${relative}`)
|
|
58
|
+
}
|
|
59
|
+
const resolved = path.resolve(appDir, relative)
|
|
60
|
+
const stayed = path.relative(appDir, resolved)
|
|
61
|
+
if (stayed.startsWith('..') || path.isAbsolute(stayed)) {
|
|
62
|
+
throw new FileToolError('path-escape', `path escapes the app directory: ${relative}`)
|
|
63
|
+
}
|
|
64
|
+
return resolved
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isAbsolute(value: string): boolean {
|
|
68
|
+
return path.posix.isAbsolute(value) || path.win32.isAbsolute(value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function listFiles(appDir: string): Promise<Array<{ path: string; bytes: number }>> {
|
|
72
|
+
const out: Array<{ path: string; bytes: number }> = []
|
|
73
|
+
await walk(appDir, appDir, out)
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function walk(appDir: string, current: string, out: Array<{ path: string; bytes: number }>): Promise<void> {
|
|
78
|
+
const entries = await readdir(current, { withFileTypes: true })
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (SKIP.has(entry.name)) continue
|
|
81
|
+
const full = path.join(current, entry.name)
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
await walk(appDir, full, out)
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
if (!entry.isFile()) continue
|
|
87
|
+
const info = await stat(full)
|
|
88
|
+
out.push({ path: path.relative(appDir, full).split(path.sep).join('/'), bytes: info.size })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function readAppFile(
|
|
93
|
+
appDir: string,
|
|
94
|
+
relative: string,
|
|
95
|
+
window: { start?: number; end?: number } | undefined,
|
|
96
|
+
numbered: boolean,
|
|
97
|
+
maxLines: number | undefined,
|
|
98
|
+
): Promise<FileRead> {
|
|
99
|
+
const full = resolveAppPath(appDir, relative)
|
|
100
|
+
const info = await stat(full).catch(() => undefined)
|
|
101
|
+
if (info === undefined) throw new FileToolError('file-missing', `file is missing: ${relative}`)
|
|
102
|
+
if (info.isDirectory()) throw new FileToolError('path-is-directory', `path is a directory: ${relative}`)
|
|
103
|
+
const text = await readFile(full, 'utf8')
|
|
104
|
+
const lines = text.split(/\r?\n/)
|
|
105
|
+
const totalLines = lines.length
|
|
106
|
+
const start = window?.start ?? 1
|
|
107
|
+
const requestedEnd = window?.end ?? totalLines
|
|
108
|
+
const cappedEnd = maxLines === undefined ? requestedEnd : Math.min(requestedEnd, start + maxLines - 1)
|
|
109
|
+
const end = Math.min(cappedEnd, totalLines)
|
|
110
|
+
const slice = lines.slice(Math.max(0, start - 1), end)
|
|
111
|
+
const content = numbered
|
|
112
|
+
? slice.map((line, index) => `${start + index}|${line}`).join('\n')
|
|
113
|
+
: slice.join('\n')
|
|
114
|
+
const truncated = end < requestedEnd || end < totalLines && window?.end === undefined && maxLines !== undefined
|
|
115
|
+
return {
|
|
116
|
+
path: relative.split(/[\\/]/).join('/'),
|
|
117
|
+
content,
|
|
118
|
+
bytes: info.size,
|
|
119
|
+
totalLines,
|
|
120
|
+
startLine: start,
|
|
121
|
+
endLine: end,
|
|
122
|
+
...truncated ? { truncated: true } : {},
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function editAppFile(
|
|
127
|
+
appDir: string,
|
|
128
|
+
relative: string,
|
|
129
|
+
edits: Array<{ oldText: string; newText: string }>,
|
|
130
|
+
commit: boolean,
|
|
131
|
+
committer: ((message: string) => Promise<FileCommit>) | undefined,
|
|
132
|
+
protectedPath: ((portablePath: string) => boolean) | undefined,
|
|
133
|
+
): Promise<{ path: string; commit: FileCommit }> {
|
|
134
|
+
rejectOwned(relative, protectedPath)
|
|
135
|
+
const full = resolveAppPath(appDir, relative)
|
|
136
|
+
let text: string
|
|
137
|
+
try {
|
|
138
|
+
text = await readFile(full, 'utf8')
|
|
139
|
+
} catch {
|
|
140
|
+
throw new FileToolError('file-missing', `file is missing: ${relative}`)
|
|
141
|
+
}
|
|
142
|
+
for (const edit of edits) {
|
|
143
|
+
const at = text.indexOf(edit.oldText)
|
|
144
|
+
if (at < 0 || text.indexOf(edit.oldText, at + 1) >= 0) {
|
|
145
|
+
throw new FileToolError('edit-not-unique', 'oldText is missing or appears more than once')
|
|
146
|
+
}
|
|
147
|
+
text = `${text.slice(0, at)}${edit.newText}${text.slice(at + edit.oldText.length)}`
|
|
148
|
+
}
|
|
149
|
+
await writeFile(full, text)
|
|
150
|
+
return { path: relative.split(/[\\/]/).join('/'), commit: await finishCommit(commit, committer, `edit ${relative}`) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function writeAppFile(
|
|
154
|
+
appDir: string,
|
|
155
|
+
relative: string,
|
|
156
|
+
content: string,
|
|
157
|
+
commit: boolean,
|
|
158
|
+
committer: ((message: string) => Promise<FileCommit>) | undefined,
|
|
159
|
+
protectedPath: ((portablePath: string) => boolean) | undefined,
|
|
160
|
+
): Promise<{ path: string; commit: FileCommit }> {
|
|
161
|
+
rejectOwned(relative, protectedPath)
|
|
162
|
+
const full = resolveAppPath(appDir, relative)
|
|
163
|
+
const info = await stat(full).catch(() => undefined)
|
|
164
|
+
if (info?.isDirectory()) throw new FileToolError('path-is-directory', `path is a directory: ${relative}`)
|
|
165
|
+
await mkdir(path.dirname(full), { recursive: true })
|
|
166
|
+
await writeFile(full, content)
|
|
167
|
+
return { path: relative.split(/[\\/]/).join('/'), commit: await finishCommit(commit, committer, `write ${relative}`) }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function deleteAppFile(
|
|
171
|
+
appDir: string,
|
|
172
|
+
relative: string,
|
|
173
|
+
commit: boolean,
|
|
174
|
+
committer: ((message: string) => Promise<FileCommit>) | undefined,
|
|
175
|
+
protectedPath: ((portablePath: string) => boolean) | undefined,
|
|
176
|
+
): Promise<{ path: string; commit: FileCommit }> {
|
|
177
|
+
rejectProtected(relative, protectedPath)
|
|
178
|
+
const full = resolveAppPath(appDir, relative)
|
|
179
|
+
try {
|
|
180
|
+
await rm(full)
|
|
181
|
+
} catch {
|
|
182
|
+
throw new FileToolError('file-missing', `file is missing: ${relative}`)
|
|
183
|
+
}
|
|
184
|
+
return { path: relative.split(/[\\/]/).join('/'), commit: await finishCommit(commit, committer, `delete ${relative}`) }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function rejectOwned(
|
|
188
|
+
relative: string,
|
|
189
|
+
protectedPath: ((portablePath: string) => boolean) | undefined,
|
|
190
|
+
): void {
|
|
191
|
+
const portable = relative.split(/[\/]/).join('/')
|
|
192
|
+
if (protectedPath?.(portable) === true) {
|
|
193
|
+
throw new FileToolError('manifest-protected', `required entry cannot be changed: ${portable}`)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function rejectProtected(
|
|
198
|
+
relative: string,
|
|
199
|
+
protectedPath: ((portablePath: string) => boolean) | undefined,
|
|
200
|
+
): void {
|
|
201
|
+
const portable = relative.split(/[\/]/).join('/')
|
|
202
|
+
if (PROTECTED.has(portable) || protectedPath?.(portable) === true) {
|
|
203
|
+
throw new FileToolError('manifest-protected', `required entry cannot be changed: ${portable}`)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function finishCommit(
|
|
208
|
+
commit: boolean,
|
|
209
|
+
committer: ((message: string) => Promise<FileCommit>) | undefined,
|
|
210
|
+
message: string,
|
|
211
|
+
): Promise<FileCommit> {
|
|
212
|
+
if (!commit) return { status: 'skipped' }
|
|
213
|
+
if (committer === undefined) return { status: 'failed', reason: 'history is not wired' }
|
|
214
|
+
return committer(message)
|
|
215
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Codes the history implementation emits. */
|
|
2
|
+
|
|
3
|
+
export const historyCodes = ['history-empty-message', 'history-unknown-commit', 'commit-failed'] as const
|
|
4
|
+
|
|
5
|
+
/** A failure a caller matches. The message is for a person. */
|
|
6
|
+
export type HistoryCode = (typeof historyCodes)[number]
|
|
7
|
+
|
|
8
|
+
/** Failure while reading or moving app history. */
|
|
9
|
+
export class HistoryError extends Error {
|
|
10
|
+
readonly code: HistoryCode
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param code - one of {@link historyCodes}
|
|
14
|
+
* @param message - human text; not the match key
|
|
15
|
+
* @param options - optional `cause`
|
|
16
|
+
*/
|
|
17
|
+
constructor(code: HistoryCode, message: string, options?: { cause?: unknown }) {
|
|
18
|
+
super(message, options)
|
|
19
|
+
this.name = 'HistoryError'
|
|
20
|
+
this.code = code
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import git from 'isomorphic-git'
|
|
6
|
+
|
|
7
|
+
import type { FileCommit } from '../files/tools.ts'
|
|
8
|
+
import { gitDir, snapshotSkip } from '../files/skip.ts'
|
|
9
|
+
import { HistoryError } from './codes.ts'
|
|
10
|
+
|
|
11
|
+
/** Host identity for snapshots. Not a person, and not a locked product field. */
|
|
12
|
+
const AUTHOR = { name: 'mini-app', email: 'history@localhost' }
|
|
13
|
+
|
|
14
|
+
/** List depth when the caller omits one. Host policy, not a locked number. */
|
|
15
|
+
const DEFAULT_LIST_LIMIT = 50
|
|
16
|
+
|
|
17
|
+
/** Preview length for an owner commit read. Host policy, not a locked number. */
|
|
18
|
+
const PREVIEW_LINES = 40
|
|
19
|
+
|
|
20
|
+
const SKIP = snapshotSkip
|
|
21
|
+
|
|
22
|
+
export interface HistoryNode {
|
|
23
|
+
id: string
|
|
24
|
+
message: string
|
|
25
|
+
time: string
|
|
26
|
+
parentIds: string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface HistoryTip {
|
|
30
|
+
name: string
|
|
31
|
+
commitId: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Commit the app source snapshot. Storage, history metadata, and `node_modules` stay out.
|
|
36
|
+
* A clean tree does not create an empty commit.
|
|
37
|
+
* @param appDir - absolute app directory
|
|
38
|
+
* @param message - required commit message
|
|
39
|
+
*/
|
|
40
|
+
export async function commitApp(appDir: string, message: string): Promise<FileCommit & { id?: string }> {
|
|
41
|
+
if (message.trim() === '') throw new HistoryError('history-empty-message', 'history commit message is empty')
|
|
42
|
+
await ensureRepo(appDir)
|
|
43
|
+
try {
|
|
44
|
+
const changed = await stage(appDir)
|
|
45
|
+
if (!changed) return { status: 'clean' }
|
|
46
|
+
const id = await git.commit({
|
|
47
|
+
fs,
|
|
48
|
+
dir: appDir,
|
|
49
|
+
message,
|
|
50
|
+
author: AUTHOR,
|
|
51
|
+
committer: AUTHOR,
|
|
52
|
+
})
|
|
53
|
+
return { status: 'committed', id }
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw new HistoryError('commit-failed', 'history commit failed', { cause: error })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** First and latest commit times on `main`. Both are omitted when the repo has no commit. */
|
|
60
|
+
export async function historyBounds(appDir: string): Promise<{ createdAt?: string; updatedAt?: string }> {
|
|
61
|
+
if (!fs.existsSync(path.join(appDir, gitDir))) return {}
|
|
62
|
+
const head = await git.resolveRef({ fs, dir: appDir, ref: 'main' }).catch(() => null)
|
|
63
|
+
if (head === null) return {}
|
|
64
|
+
const latest = await git.readCommit({ fs, dir: appDir, oid: head })
|
|
65
|
+
let created = latest
|
|
66
|
+
let parent = latest.commit.parent[0]
|
|
67
|
+
const seen = new Set<string>([head])
|
|
68
|
+
while (parent !== undefined && !seen.has(parent)) {
|
|
69
|
+
seen.add(parent)
|
|
70
|
+
created = await git.readCommit({ fs, dir: appDir, oid: parent })
|
|
71
|
+
parent = created.commit.parent[0]
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
createdAt: commitTime(created.commit.committer.timestamp),
|
|
75
|
+
updatedAt: commitTime(latest.commit.committer.timestamp),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* List commits on `main` and the backup tips. An empty repo is an empty history.
|
|
81
|
+
* @param appDir - absolute app directory
|
|
82
|
+
* @param limit - caller cap; omitted uses host policy
|
|
83
|
+
*/
|
|
84
|
+
export async function listHistory(appDir: string, limit = DEFAULT_LIST_LIMIT): Promise<{
|
|
85
|
+
head: string | null
|
|
86
|
+
nodes: HistoryNode[]
|
|
87
|
+
tips: HistoryTip[]
|
|
88
|
+
}> {
|
|
89
|
+
if (!fs.existsSync(path.join(appDir, gitDir))) return { head: null, nodes: [], tips: [] }
|
|
90
|
+
const head = await git.resolveRef({ fs, dir: appDir, ref: 'main' }).catch(() => null)
|
|
91
|
+
if (head === null) return { head: null, nodes: [], tips: [] }
|
|
92
|
+
const commits = await git.log({ fs, dir: appDir, ref: 'main', depth: limit })
|
|
93
|
+
const refs = await git.listRefs({ fs, dir: appDir, filepath: 'refs' })
|
|
94
|
+
const tips: HistoryTip[] = []
|
|
95
|
+
for (const name of refs) {
|
|
96
|
+
const commitId = await git.resolveRef({ fs, dir: appDir, ref: name }).catch(() => undefined)
|
|
97
|
+
if (commitId !== undefined) tips.push({ name, commitId })
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
head,
|
|
101
|
+
nodes: commits.map(entry => ({
|
|
102
|
+
id: entry.oid,
|
|
103
|
+
message: entry.commit.message.trim(),
|
|
104
|
+
time: commitTime(entry.commit.committer.timestamp),
|
|
105
|
+
parentIds: entry.commit.parent,
|
|
106
|
+
})),
|
|
107
|
+
tips,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Make the working tree and `main` match `commitId`. The previous head stays a backup ref.
|
|
113
|
+
* A tree that already matches does not write a backup ref.
|
|
114
|
+
* @param appDir - absolute app directory
|
|
115
|
+
* @param commitId - full commit id, or a ref that resolves to one
|
|
116
|
+
*/
|
|
117
|
+
export async function resetApp(appDir: string, commitId: string): Promise<{
|
|
118
|
+
head: string
|
|
119
|
+
backupRef?: string
|
|
120
|
+
changed: boolean
|
|
121
|
+
files: string[]
|
|
122
|
+
}> {
|
|
123
|
+
await ensureRepo(appDir)
|
|
124
|
+
const target = await resolveCommit(appDir, commitId)
|
|
125
|
+
const previous = await git.resolveRef({ fs, dir: appDir, ref: 'HEAD' }).catch(() => undefined)
|
|
126
|
+
const changedRows = await diffAgainst(appDir, target)
|
|
127
|
+
const headMoves = previous !== undefined && previous !== target
|
|
128
|
+
if (changedRows.length === 0 && !headMoves) {
|
|
129
|
+
return { head: target, changed: false, files: [] }
|
|
130
|
+
}
|
|
131
|
+
let backupRef: string | undefined
|
|
132
|
+
if (previous !== undefined) {
|
|
133
|
+
backupRef = `refs/mini-app/backup/${previous}`
|
|
134
|
+
await git.writeRef({ fs, dir: appDir, ref: backupRef, value: previous, force: true })
|
|
135
|
+
}
|
|
136
|
+
const headFiles = previous === undefined ? [] : await git.listFiles({ fs, dir: appDir, ref: previous })
|
|
137
|
+
const targetFiles = new Set(await git.listFiles({ fs, dir: appDir, ref: target }))
|
|
138
|
+
const files = [...new Set([
|
|
139
|
+
...changedRows,
|
|
140
|
+
...headFiles.filter(file => !targetFiles.has(file) && !skipped(file)),
|
|
141
|
+
])]
|
|
142
|
+
await git.checkout({ fs, dir: appDir, ref: target, force: true })
|
|
143
|
+
return {
|
|
144
|
+
head: target,
|
|
145
|
+
...backupRef === undefined ? {} : { backupRef },
|
|
146
|
+
changed: true,
|
|
147
|
+
files,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Owner read of one commit. The preview is not every changed line.
|
|
153
|
+
* @param appDir - absolute app directory
|
|
154
|
+
* @param commitId - full commit id
|
|
155
|
+
*/
|
|
156
|
+
export async function readAppCommit(appDir: string, commitId: string): Promise<{
|
|
157
|
+
message: string
|
|
158
|
+
time: string
|
|
159
|
+
parentIds: string[]
|
|
160
|
+
files: Array<{ path: string; add: number; del: number; preview: string }>
|
|
161
|
+
}> {
|
|
162
|
+
const oid = await resolveCommit(appDir, commitId)
|
|
163
|
+
const commit = await git.readCommit({ fs, dir: appDir, oid })
|
|
164
|
+
const parent = commit.commit.parent[0]
|
|
165
|
+
const current = await git.listFiles({ fs, dir: appDir, ref: oid })
|
|
166
|
+
const previous = parent === undefined ? [] : await git.listFiles({ fs, dir: appDir, ref: parent })
|
|
167
|
+
const paths = [...new Set([...current, ...previous])].filter(file => !skipped(file)).sort()
|
|
168
|
+
const files = []
|
|
169
|
+
for (const file of paths) {
|
|
170
|
+
const after = current.includes(file) ? await blobText(appDir, oid, file) : ''
|
|
171
|
+
const before = parent !== undefined && previous.includes(file) ? await blobText(appDir, parent, file) : ''
|
|
172
|
+
const counted = lineCounts(before, after)
|
|
173
|
+
if (counted.add === 0 && counted.del === 0) continue
|
|
174
|
+
files.push({ path: file, ...counted })
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
message: commit.commit.message.trim(),
|
|
178
|
+
time: new Date(commit.commit.committer.timestamp * 1000).toISOString(),
|
|
179
|
+
parentIds: commit.commit.parent,
|
|
180
|
+
files,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function ensureRepo(appDir: string): Promise<void> {
|
|
185
|
+
if (fs.existsSync(path.join(appDir, gitDir))) return
|
|
186
|
+
await git.init({ fs, dir: appDir, defaultBranch: 'main' })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function stage(appDir: string): Promise<boolean> {
|
|
190
|
+
const head = await git.resolveRef({ fs, dir: appDir, ref: 'HEAD' }).catch(() => undefined)
|
|
191
|
+
const allTracked = head === undefined ? [] : await git.listFiles({ fs, dir: appDir, ref: head })
|
|
192
|
+
const tracked = allTracked.filter(file => !skipped(file))
|
|
193
|
+
const disk = await walk(appDir)
|
|
194
|
+
let changed = false
|
|
195
|
+
for (const file of disk) {
|
|
196
|
+
const current = await readFile(filePath(appDir, file))
|
|
197
|
+
const previous = head === undefined || !tracked.includes(file)
|
|
198
|
+
? undefined
|
|
199
|
+
: await git.readBlob({ fs, dir: appDir, oid: head, filepath: file })
|
|
200
|
+
if (previous === undefined || !Buffer.from(previous.blob).equals(current)) {
|
|
201
|
+
await git.add({ fs, dir: appDir, filepath: file })
|
|
202
|
+
changed = true
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
for (const file of allTracked) {
|
|
206
|
+
if (!skipped(file) && disk.includes(file)) continue
|
|
207
|
+
await git.remove({ fs, dir: appDir, filepath: file })
|
|
208
|
+
changed = true
|
|
209
|
+
}
|
|
210
|
+
return changed
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function diffAgainst(appDir: string, oid: string): Promise<string[]> {
|
|
214
|
+
const tracked = (await git.listFiles({ fs, dir: appDir, ref: oid })).filter(file => !skipped(file))
|
|
215
|
+
const disk = new Set(await walk(appDir))
|
|
216
|
+
const files: string[] = []
|
|
217
|
+
for (const file of tracked) {
|
|
218
|
+
if (!disk.has(file)) {
|
|
219
|
+
files.push(file)
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
const blob = await git.readBlob({ fs, dir: appDir, oid, filepath: file })
|
|
223
|
+
const current = await readFile(filePath(appDir, file))
|
|
224
|
+
if (!Buffer.from(blob.blob).equals(current)) files.push(file)
|
|
225
|
+
}
|
|
226
|
+
return files
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function filePath(appDir: string, file: string): string {
|
|
230
|
+
return path.join(appDir, ...file.split('/'))
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function resolveCommit(appDir: string, commitId: string): Promise<string> {
|
|
234
|
+
try {
|
|
235
|
+
return await git.resolveRef({ fs, dir: appDir, ref: commitId })
|
|
236
|
+
} catch (error) {
|
|
237
|
+
throw new HistoryError('history-unknown-commit', `unknown commit: ${commitId}`, { cause: error })
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function blobText(appDir: string, oid: string, filepath: string): Promise<string> {
|
|
242
|
+
const blob = await git.readBlob({ fs, dir: appDir, oid, filepath })
|
|
243
|
+
return new TextDecoder().decode(blob.blob)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function lineCounts(before: string, after: string): { add: number; del: number; preview: string } {
|
|
247
|
+
const oldLines = before === '' ? [] : before.split('\n')
|
|
248
|
+
const nextLines = after === '' ? [] : after.split('\n')
|
|
249
|
+
const bag = new Map<string, number>()
|
|
250
|
+
for (const line of oldLines) bag.set(line, (bag.get(line) ?? 0) + 1)
|
|
251
|
+
let add = 0
|
|
252
|
+
let del = 0
|
|
253
|
+
const preview: string[] = []
|
|
254
|
+
for (const line of nextLines) {
|
|
255
|
+
const left = bag.get(line) ?? 0
|
|
256
|
+
if (left > 0) bag.set(line, left - 1)
|
|
257
|
+
else {
|
|
258
|
+
add += 1
|
|
259
|
+
if (preview.length < PREVIEW_LINES) preview.push(`+${line}`)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const [line, count] of bag) {
|
|
263
|
+
del += count
|
|
264
|
+
for (let index = 0; index < count && preview.length < PREVIEW_LINES; index += 1) preview.push(`-${line}`)
|
|
265
|
+
}
|
|
266
|
+
return { add, del, preview: preview.join('\n') }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function commitTime(seconds: number): string {
|
|
270
|
+
return new Date(seconds * 1000).toISOString()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function walk(dir: string, root = dir): Promise<string[]> {
|
|
274
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
275
|
+
const files: string[] = []
|
|
276
|
+
for (const entry of entries) {
|
|
277
|
+
if (SKIP.has(entry.name)) continue
|
|
278
|
+
const full = path.join(dir, entry.name)
|
|
279
|
+
if (entry.isDirectory()) files.push(...await walk(full, root))
|
|
280
|
+
else files.push(path.relative(root, full).split(path.sep).join('/'))
|
|
281
|
+
}
|
|
282
|
+
return files
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function skipped(filepath: string): boolean {
|
|
286
|
+
return filepath.split('/').some(part => SKIP.has(part))
|
|
287
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import type { HeatValue } from './heat.ts'
|
|
4
|
+
import { hostActivityPath } from './layout.ts'
|
|
5
|
+
|
|
6
|
+
/** The activity file is present and not this shape. The file is left as it is. */
|
|
7
|
+
export class ActivityError extends Error {
|
|
8
|
+
readonly code: 'activity-invalid' | 'activity-unreadable'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param code - `activity-invalid` is a bad document; `activity-unreadable` is a present file that cannot be read
|
|
12
|
+
* @param message - human text
|
|
13
|
+
* @param options - optional `cause`
|
|
14
|
+
*/
|
|
15
|
+
constructor(code: 'activity-invalid' | 'activity-unreadable', message: string, options?: { cause?: unknown }) {
|
|
16
|
+
super(message, options)
|
|
17
|
+
this.name = 'ActivityError'
|
|
18
|
+
this.code = code
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One app's usage. Further fields can sit beside the count and the time. */
|
|
23
|
+
export interface ActivityApp extends HeatValue {}
|
|
24
|
+
|
|
25
|
+
/** Usage on this machine. Not source history, and not `host.json`. */
|
|
26
|
+
export interface Activity {
|
|
27
|
+
readonly apps: Readonly<Record<string, ActivityApp>>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read `activity.json`. A missing file is no activity.
|
|
32
|
+
* A bad document fails and is not an empty map.
|
|
33
|
+
* @param runtimeRoot - host runtime root
|
|
34
|
+
*/
|
|
35
|
+
export function readActivity(runtimeRoot: string): Activity {
|
|
36
|
+
let text: string
|
|
37
|
+
try {
|
|
38
|
+
text = readFileSync(hostActivityPath(runtimeRoot), 'utf8')
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (isMissing(error)) return { apps: {} }
|
|
41
|
+
throw new ActivityError('activity-unreadable', 'activity file cannot be read', { cause: error })
|
|
42
|
+
}
|
|
43
|
+
return admitActivity(text)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Record one open and write the file. The count increases by one.
|
|
48
|
+
* `lastOpenedAt` becomes the later of the stored time and `openedAt`.
|
|
49
|
+
* @param runtimeRoot - host runtime root
|
|
50
|
+
* @param appId - registered app id
|
|
51
|
+
* @param openedAt - time of this open
|
|
52
|
+
*/
|
|
53
|
+
export function recordOpen(runtimeRoot: string, appId: string, openedAt: string): Activity {
|
|
54
|
+
if (appId === '' || openedAt === '') throw new ActivityError('activity-invalid', 'open is missing a field')
|
|
55
|
+
const current = readActivity(runtimeRoot)
|
|
56
|
+
const previous = current.apps[appId]
|
|
57
|
+
const next: Activity = {
|
|
58
|
+
apps: {
|
|
59
|
+
...current.apps,
|
|
60
|
+
[appId]: {
|
|
61
|
+
...previous,
|
|
62
|
+
openCount: (previous?.openCount ?? 0) + 1,
|
|
63
|
+
lastOpenedAt: previous !== undefined && previous.lastOpenedAt > openedAt ? previous.lastOpenedAt : openedAt,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
mkdirSync(runtimeRoot, { recursive: true })
|
|
68
|
+
writeFileSync(hostActivityPath(runtimeRoot), `${JSON.stringify(next, null, 2)}\n`)
|
|
69
|
+
return next
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function admitActivity(text: string): Activity {
|
|
73
|
+
let value: unknown
|
|
74
|
+
try {
|
|
75
|
+
value = JSON.parse(text)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
throw new ActivityError('activity-invalid', 'activity file is not the activity document', { cause: error })
|
|
78
|
+
}
|
|
79
|
+
if (!isAppsRecord(value)) throw new ActivityError('activity-invalid', 'activity file is not the activity document')
|
|
80
|
+
const apps: Record<string, ActivityApp> = {}
|
|
81
|
+
for (const [appId, app] of Object.entries(value.apps)) {
|
|
82
|
+
apps[appId] = admitApp(app)
|
|
83
|
+
}
|
|
84
|
+
return { apps }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isAppsRecord(value: unknown): value is { apps: Record<string, unknown> } {
|
|
88
|
+
return typeof value === 'object'
|
|
89
|
+
&& value !== null
|
|
90
|
+
&& 'apps' in value
|
|
91
|
+
&& typeof value.apps === 'object'
|
|
92
|
+
&& value.apps !== null
|
|
93
|
+
&& !Array.isArray(value.apps)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function admitApp(value: unknown): ActivityApp {
|
|
97
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
98
|
+
throw new ActivityError('activity-invalid', 'activity app is not an object')
|
|
99
|
+
}
|
|
100
|
+
const openCount = 'openCount' in value ? value.openCount : undefined
|
|
101
|
+
const lastOpenedAt = 'lastOpenedAt' in value ? value.lastOpenedAt : undefined
|
|
102
|
+
if (typeof openCount !== 'number' || typeof lastOpenedAt !== 'string' || lastOpenedAt === '') {
|
|
103
|
+
throw new ActivityError('activity-invalid', 'activity is not a count and a time')
|
|
104
|
+
}
|
|
105
|
+
return { ...value, openCount, lastOpenedAt }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isMissing(error: unknown): boolean {
|
|
109
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'
|
|
110
|
+
}
|