acrylblends 1.4.0 → 1.5.1
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 +5 -2
- package/dist/index.d.ts +7 -4
- package/dist/index.js +191 -62
- package/dist/index.js.map +1 -1
- package/legacy/README.md +1 -1
- package/legacy/agent/46c6eadf9230/src/app.ts +293 -0
- package/legacy/agent/46c6eadf9230/src/routes.ts +163 -0
- package/legacy/blank/a53d41e6ef42/src/app.ts +160 -0
- package/legacy/blank/a53d41e6ef42/src/routes.ts +79 -0
- package/legacy/layouts.json +30 -0
- package/lib/{client-modules-Bkdr83jO.js → client-modules-C5qiVroz.js} +1 -1
- package/lib/cordis/door.js +1 -1
- package/lib/node-server/register.js +6 -1
- package/lib/node-server.js +1 -1
- package/lib/{transpile-QUK9b3eG.js → transpile-CRRaixyS.js} +19 -4
- package/package.json +1 -1
- package/plugins/acryl-door/src/cli/acryl.ts +6 -2
- package/plugins/acryl-node-server/src/register.ts +10 -1
- package/plugins/acryl-node-server/src/transpile.ts +25 -6
- package/plugins/acryl-page/src/favicon.ts +19 -0
- package/plugins/acryl-page/src/index.ts +18 -13
- package/plugins/agent-panel/client/main.ts +9 -0
- package/plugins/agent-panel/headless.ts +2 -2
- package/plugins/desktop/app-page.ts +151 -0
- package/plugins/desktop/assets/acrylblends-logo.png +0 -0
- package/plugins/desktop/brand.ts +164 -0
- package/plugins/desktop/frame.ts +88 -7
- package/plugins/desktop/git.ts +44 -0
- package/plugins/desktop/host-supervisor.ts +63 -0
- package/plugins/desktop/launch.ts +17 -1
- package/plugins/desktop/main.ts +268 -17
- package/plugins/desktop/preload.cjs +8 -0
- package/plugins/desktop/product.ts +117 -0
- package/plugins/desktop/window.ts +4 -0
- package/plugins/desktop-web/main.ts +3 -2
- package/plugins/desktop-web/plugin.json +1 -1
- package/plugins/extensions/index.ts +52 -7
- package/plugins/instance/archive.ts +395 -0
- package/plugins/instance/bundle-overlay.ts +10 -0
- package/plugins/instance/bundle.ts +201 -0
- package/plugins/instance/home.ts +395 -0
- package/plugins/instance/index.ts +132 -24
- package/plugins/instance/storage.ts +413 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +1 -1
- package/templates/agent/README.md +1 -1
- package/templates/agent/gitignore +2 -0
- package/templates/blank/README.md +1 -1
- package/templates/blank/gitignore +2 -0
- package/templates/blank/src/routes.ts +9 -5
- package/templates/chat/gitignore +2 -0
- package/templates/chat/src/chat-app.ts +7 -3
- package/templates/chat/src/llm.ts +7 -1
- package/templates/chat/test/chat.test.ts +16 -1
- package/templates/nodejs/gitignore +2 -0
- package/templates/notes/gitignore +2 -0
- package/templates/todo/README.md +1 -1
- package/templates/todo/gitignore +2 -0
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
statSync,
|
|
25
25
|
writeFileSync,
|
|
26
26
|
} from 'node:fs'
|
|
27
|
-
import { basename, isAbsolute, join, relative, resolve } from 'node:path'
|
|
27
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
28
28
|
import { pathToFileURL } from 'node:url'
|
|
29
29
|
import { isDeepStrictEqual } from 'node:util'
|
|
30
30
|
import { type Context, type Fiber, Service } from '@deepseek-ai/cordis'
|
|
@@ -216,6 +216,22 @@ const inside = (path: string, folder: string) => {
|
|
|
216
216
|
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* A path as `extensions.json` stores it: relative to `base` (with `/`) when it is inside it, so the record survives a moved app folder (`base` the app's
|
|
221
|
+
* folder for a source, the app's data home for a staged copy); an absolute path stays absolute.
|
|
222
|
+
*/
|
|
223
|
+
export function storedPath(path: string, base: string): string {
|
|
224
|
+
const relativePath = relative(base, path)
|
|
225
|
+
return relativePath !== '' && !relativePath.startsWith('..') && !isAbsolute(relativePath)
|
|
226
|
+
? relativePath.split(sep).join('/')
|
|
227
|
+
: path
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The inverse of {@link storedPath}: a relative path is read from `base`. */
|
|
231
|
+
export function restoredPath(stored: string, base: string): string {
|
|
232
|
+
return isAbsolute(stored) ? stored : resolve(base, stored)
|
|
233
|
+
}
|
|
234
|
+
|
|
219
235
|
function injected(module: PluginModule): string[] {
|
|
220
236
|
const inject = module.inject
|
|
221
237
|
if (Array.isArray(inject))
|
|
@@ -259,6 +275,9 @@ export class Extensions extends Service {
|
|
|
259
275
|
private readonly owner: Context
|
|
260
276
|
private readonly store: PluginStore
|
|
261
277
|
private readonly routePrefix: string
|
|
278
|
+
/** The app's folder and its data home: what the record's relative paths are read from. */
|
|
279
|
+
private readonly appRoot: string
|
|
280
|
+
private readonly dataHome: string
|
|
262
281
|
|
|
263
282
|
constructor(ctx: Context, config: ExtensionsConfig, store: PluginStore) {
|
|
264
283
|
super(ctx, 'extensions')
|
|
@@ -270,6 +289,8 @@ export class Extensions extends Service {
|
|
|
270
289
|
this.rows = new Set(config.rows ?? [])
|
|
271
290
|
this.say = config.log ?? ((line) => console.log(line))
|
|
272
291
|
const { home, root } = ctx.appInstance
|
|
292
|
+
this.appRoot = root
|
|
293
|
+
this.dataHome = home
|
|
273
294
|
this.sources = (config.sources ?? [{ dir: join(home, 'extensions'), trust: 'install' }]).map(
|
|
274
295
|
(source) => ({
|
|
275
296
|
dir: resolve(source.dir),
|
|
@@ -660,7 +681,11 @@ export class Extensions extends Service {
|
|
|
660
681
|
private saveState(): void {
|
|
661
682
|
const records: Record<string, Record_> = {}
|
|
662
683
|
for (const entry of this.installed.values())
|
|
663
|
-
records[entry.name] = {
|
|
684
|
+
records[entry.name] = {
|
|
685
|
+
source: storedPath(entry.source, this.appRoot),
|
|
686
|
+
version: entry.version,
|
|
687
|
+
dir: storedPath(entry.dir, this.dataHome),
|
|
688
|
+
}
|
|
664
689
|
mkdirSync(join(this.stateFile, '..'), { recursive: true })
|
|
665
690
|
const temporary = `${this.stateFile}.${String(process.pid)}.tmp`
|
|
666
691
|
writeFileSync(temporary, `${JSON.stringify(records, null, 2)}\n`)
|
|
@@ -857,6 +882,21 @@ export class Extensions extends Service {
|
|
|
857
882
|
return summary
|
|
858
883
|
}
|
|
859
884
|
|
|
885
|
+
/**
|
|
886
|
+
* A record with its paths made absolute: relative ones are read from this app's folder and data home, so a moved app finds its plugins. A record an older
|
|
887
|
+
* version wrote with an absolute source that is gone (the app was moved) finds the folder of the same name in one of the sources.
|
|
888
|
+
*/
|
|
889
|
+
private resolved(name: string, stored: Record_): Record_ {
|
|
890
|
+
let source = restoredPath(stored.source, this.appRoot)
|
|
891
|
+
if (!existsSync(source)) {
|
|
892
|
+
const moved = this.sources
|
|
893
|
+
.map((entry) => join(entry.dir, name))
|
|
894
|
+
.find((path) => existsSync(path))
|
|
895
|
+
if (moved !== undefined) source = moved
|
|
896
|
+
}
|
|
897
|
+
return { source, version: stored.version, dir: restoredPath(stored.dir, this.dataHome) }
|
|
898
|
+
}
|
|
899
|
+
|
|
860
900
|
private async startup(): Promise<StartupSummary> {
|
|
861
901
|
if (this.readOnly) return this.startupReadOnly()
|
|
862
902
|
const summary: StartupSummary = { installed: [], pending: [], stale: [], failed: [] }
|
|
@@ -867,21 +907,26 @@ export class Extensions extends Service {
|
|
|
867
907
|
} catch (error) {
|
|
868
908
|
summary.failed.push({ name: 'extensions.json', error: message(error) })
|
|
869
909
|
}
|
|
870
|
-
for (const [name,
|
|
910
|
+
for (const [name, stored] of Object.entries(records)) {
|
|
871
911
|
// Switched off: not started, and dropped from the record; switched on again, its folder is found and installed as new.
|
|
872
912
|
if (this.off.has(name)) continue
|
|
873
913
|
// Now a row of blend.yaml: the app starts it, and the record of its install is dropped.
|
|
874
914
|
if (this.rows.has(name)) continue
|
|
915
|
+
const record = this.resolved(name, stored)
|
|
875
916
|
try {
|
|
876
917
|
// What was installed before was approved then: an unchanged source starts again without asking.
|
|
877
918
|
this.approved.set(name, record.version)
|
|
878
919
|
if (existsSync(record.source)) {
|
|
879
920
|
const result = await this.installNow(record.source)
|
|
880
|
-
if (result.ok)
|
|
881
|
-
|
|
882
|
-
|
|
921
|
+
if (result.ok) {
|
|
922
|
+
summary.installed.push(name)
|
|
923
|
+
continue
|
|
924
|
+
}
|
|
925
|
+
summary.failed.push({ name, error: result.errors.join('; ') })
|
|
926
|
+
// A source changed into one that does not start (a refused update leaves it on disk): the version that worked runs from its staged copy.
|
|
927
|
+
if (!existsSync(record.dir)) continue
|
|
883
928
|
}
|
|
884
|
-
// The source is gone: the last installed version still runs, and is reported as stale.
|
|
929
|
+
// The source is gone or broken: the last installed version still runs, and is reported as stale.
|
|
885
930
|
summary.stale.push(name)
|
|
886
931
|
this.stale.add(name)
|
|
887
932
|
if (!this.store.holds(record)) {
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An app as one file, `.acrylblend` (specs/049-framework-architecture/app-storage.md in ACRYL: "Export uses git bundle"): a git bundle of the app's source
|
|
3
|
+
* with its whole history, every branch and tag, plus two refs of its own. `refs/acrylblend/worktree` holds the files not yet saved as a version (a commit
|
|
4
|
+
* on top of HEAD, made only when they differ), so nothing the person sees is left behind. `refs/acrylblend/data` holds the app's data when the person
|
|
5
|
+
* ticks it (a root commit of `data/`: its settings and storages, see `leftOutOfData`). Keys never leave the machine in an archive: the engine's
|
|
6
|
+
* credentials file and the `.env` files it reads keys from are left out of both the unsaved files and the data.
|
|
7
|
+
*
|
|
8
|
+
* The data names the app's own folder in places (the workspace's project list, its settings): an export writes a marker there and an import the new app's
|
|
9
|
+
* folder, so an imported app never points at the app it came from. Chats are not carried yet: the engine files each one under the absolute folder it ran
|
|
10
|
+
* in, inside its compressed session log, and moving them is the engine's to offer.
|
|
11
|
+
*
|
|
12
|
+
* The source repository is never written by an export: the snapshot commits are made in a scratch repository in the app's cache that borrows the source's
|
|
13
|
+
* objects (git's alternates), and the bundle is written from there. An import makes a fresh repository in an empty source folder; the data comes through a
|
|
14
|
+
* second scratch repository, so its objects never enter the person's history.
|
|
15
|
+
*
|
|
16
|
+
* One tool, the git the product ships (or the one on PATH outside a product), on every OS.
|
|
17
|
+
*/
|
|
18
|
+
import { execFileSync } from 'node:child_process'
|
|
19
|
+
import {
|
|
20
|
+
existsSync,
|
|
21
|
+
lstatSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
mkdtempSync,
|
|
24
|
+
readdirSync,
|
|
25
|
+
readFileSync,
|
|
26
|
+
realpathSync,
|
|
27
|
+
renameSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
} from 'node:fs'
|
|
31
|
+
import { basename, extname, join } from 'node:path'
|
|
32
|
+
import { parseDocument, visit } from 'yaml'
|
|
33
|
+
import { AppInstanceError } from './index.ts'
|
|
34
|
+
|
|
35
|
+
export const ARCHIVE_EXTENSION = '.acrylblend'
|
|
36
|
+
export const WORKTREE_REF = 'refs/acrylblend/worktree'
|
|
37
|
+
export const DATA_REF = 'refs/acrylblend/data'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Where the engine reads keys from (`@deepseek-ai/dsh-credentials-local`): its credentials file in its home, and a `.env` in its home and in the folder it
|
|
41
|
+
* runs in. Never in an archive, whatever the app's own ignore rules say.
|
|
42
|
+
*/
|
|
43
|
+
export const KEY_FILES = ['**/.credentials.yaml*', '**/.env', '**/.env.*'] as const
|
|
44
|
+
|
|
45
|
+
const isKeyFile = (name: string): boolean =>
|
|
46
|
+
name.startsWith('.credentials.yaml') || name === '.env' || name.startsWith('.env.')
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a path of `data/` (relative, `/`-separated) stays out of an archive: the logs, what belongs to one run (the run lock, the frame's tokenized
|
|
50
|
+
* address, lock files), the composition the launcher writes again on every boot (`acrylblends-frame`, naming this computer's folders), the key files,
|
|
51
|
+
* and the chats with the frame's map of them (see the module comment).
|
|
52
|
+
*/
|
|
53
|
+
export function leftOutOfData(path: string): boolean {
|
|
54
|
+
const name = path.split('/').at(-1) ?? ''
|
|
55
|
+
const top = path.split('/')[0] ?? ''
|
|
56
|
+
return (
|
|
57
|
+
top === 'logs' ||
|
|
58
|
+
top === 'sessions' ||
|
|
59
|
+
path.startsWith('node_modules/acrylblends-frame/') ||
|
|
60
|
+
['instance.json', 'frame-url.json', 'frame/branch-chats.json'].includes(path) ||
|
|
61
|
+
name.endsWith('.lock') ||
|
|
62
|
+
isKeyFile(name)
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Where the data named the app's own folder, as written in an archive: the folder as it is, and as a JSON string holds it. */
|
|
67
|
+
export const SOURCE_MARK = '{{acrylblends:source}}'
|
|
68
|
+
export const SOURCE_MARK_JSON = '{{acrylblends:source-json}}'
|
|
69
|
+
|
|
70
|
+
/** A file read as text when it is one (UTF-8, no NUL, not huge); undefined for anything else, which is copied as it is. */
|
|
71
|
+
function asText(file: string): string | undefined {
|
|
72
|
+
if (lstatSync(file).size > 16 * 1024 * 1024) return undefined
|
|
73
|
+
const bytes = readFileSync(file)
|
|
74
|
+
if (bytes.includes(0)) return undefined
|
|
75
|
+
const text = bytes.toString('utf8')
|
|
76
|
+
return Buffer.from(text, 'utf8').equals(bytes) ? text : undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type Pairs = readonly (readonly [string, string])[]
|
|
80
|
+
|
|
81
|
+
const replaceAll = (text: string, pairs: Pairs): string =>
|
|
82
|
+
pairs.reduce((current, [from, to]) => current.split(from).join(to), text)
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `text` with each `[from, to]` replaced. A YAML file is changed in its string values, not its text: a writer folds a long value over lines, which no
|
|
86
|
+
* text search finds, and a value written back is quoted as YAML needs whatever the folder's name holds. YAML that does not parse is changed as text.
|
|
87
|
+
*/
|
|
88
|
+
function relocate(file: string, text: string, pairs: Pairs): string {
|
|
89
|
+
if (['.yaml', '.yml'].includes(extname(file))) {
|
|
90
|
+
const document = parseDocument(text)
|
|
91
|
+
if (document.errors.length === 0) {
|
|
92
|
+
let changed = false
|
|
93
|
+
visit(document, {
|
|
94
|
+
Scalar(_, node) {
|
|
95
|
+
if (typeof node.value !== 'string') return
|
|
96
|
+
const next = replaceAll(node.value, pairs)
|
|
97
|
+
if (next === node.value) return
|
|
98
|
+
node.value = next
|
|
99
|
+
changed = true
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
return changed ? document.toString({ lineWidth: 0 }) : text
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return replaceAll(text, pairs)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Replace each `[from, to]` in every text file under `dir`. */
|
|
109
|
+
function rewriteText(dir: string, pairs: Pairs): void {
|
|
110
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
111
|
+
const path = join(dir, entry.name)
|
|
112
|
+
if (entry.isDirectory()) rewriteText(path, pairs)
|
|
113
|
+
else if (entry.isFile()) {
|
|
114
|
+
const text = asText(path)
|
|
115
|
+
if (text === undefined) continue
|
|
116
|
+
const next = relocate(path, text, pairs)
|
|
117
|
+
if (next !== text) writeFileSync(path, next)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The ways the data may name `source`: as given and as the OS resolves it, raw and JSON-escaped, the longest first. */
|
|
123
|
+
function sourceSpellings(source: string, mark: string, markJson: string): [string, string][] {
|
|
124
|
+
let real = source
|
|
125
|
+
try {
|
|
126
|
+
real = realpathSync(source)
|
|
127
|
+
} catch {
|
|
128
|
+
// Not there (yet): named as given.
|
|
129
|
+
}
|
|
130
|
+
const pairs: [string, string][] = []
|
|
131
|
+
for (const path of new Set([real, source])) {
|
|
132
|
+
const json = JSON.stringify(path).slice(1, -1)
|
|
133
|
+
if (json !== path) pairs.push([json, markJson])
|
|
134
|
+
pairs.push([path, mark])
|
|
135
|
+
}
|
|
136
|
+
return pairs.sort((a, b) => b[0].length - a[0].length)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Copy `data` into `into` as an archive carries it: without what stays out, with the app's folder written as the marker. Links are not followed. */
|
|
140
|
+
function stageData(data: string, into: string, source: string): void {
|
|
141
|
+
const copy = (from: string, to: string, prefix: string): void => {
|
|
142
|
+
mkdirSync(to, { recursive: true })
|
|
143
|
+
for (const entry of readdirSync(from, { withFileTypes: true })) {
|
|
144
|
+
const rel = `${prefix}${entry.name}`
|
|
145
|
+
if (leftOutOfData(rel)) continue
|
|
146
|
+
if (entry.isDirectory()) copy(join(from, entry.name), join(to, entry.name), `${rel}/`)
|
|
147
|
+
else if (entry.isFile())
|
|
148
|
+
writeFileSync(join(to, entry.name), readFileSync(join(from, entry.name)))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
copy(data, into, '')
|
|
152
|
+
rewriteText(into, sourceSpellings(source, SOURCE_MARK, SOURCE_MARK_JSON))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The name a synthetic commit of an export carries: it is the product's, never the person's. */
|
|
156
|
+
const SNAPSHOT_IDENTITY = {
|
|
157
|
+
GIT_AUTHOR_NAME: 'ACRYL Blends',
|
|
158
|
+
GIT_AUTHOR_EMAIL: 'export@acrylblends.invalid',
|
|
159
|
+
GIT_COMMITTER_NAME: 'ACRYL Blends',
|
|
160
|
+
GIT_COMMITTER_EMAIL: 'export@acrylblends.invalid',
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The environment of a git call: the process's, without any `GIT_` variable it inherited (a repository or index of someone else's), never prompting. */
|
|
164
|
+
function gitEnv(extra: Readonly<Record<string, string>> = {}): NodeJS.ProcessEnv {
|
|
165
|
+
const env: NodeJS.ProcessEnv = {}
|
|
166
|
+
for (const [key, value] of Object.entries(process.env))
|
|
167
|
+
if (!key.startsWith('GIT_')) env[key] = value
|
|
168
|
+
return { ...env, GIT_TERMINAL_PROMPT: '0', ...extra }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Run git, returning its trimmed stdout; a failure says which command and what git said. */
|
|
172
|
+
function runGit(
|
|
173
|
+
git: string,
|
|
174
|
+
args: readonly string[],
|
|
175
|
+
options: { cwd?: string; env?: Readonly<Record<string, string>> } = {},
|
|
176
|
+
): string {
|
|
177
|
+
try {
|
|
178
|
+
return execFileSync(git, args, {
|
|
179
|
+
cwd: options.cwd,
|
|
180
|
+
env: gitEnv(options.env),
|
|
181
|
+
encoding: 'utf8',
|
|
182
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
183
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
184
|
+
windowsHide: true,
|
|
185
|
+
}).trim()
|
|
186
|
+
} catch (cause) {
|
|
187
|
+
const stderr = String((cause as { stderr?: unknown }).stderr ?? '').trim()
|
|
188
|
+
throw new AppInstanceError(
|
|
189
|
+
`git ${args[0] ?? ''} failed${stderr === '' ? '' : `: ${stderr.split('\n').at(-1) ?? ''}`}`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A scratch folder in the app's cache, removed whatever happens. */
|
|
195
|
+
function withScratch<T>(cache: string, use: (dir: string) => T): T {
|
|
196
|
+
mkdirSync(cache, { recursive: true })
|
|
197
|
+
const dir = mkdtempSync(join(cache, 'archive-'))
|
|
198
|
+
try {
|
|
199
|
+
return use(dir)
|
|
200
|
+
} finally {
|
|
201
|
+
rmSync(dir, { recursive: true, force: true })
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The tree of a commit made in a scratch index must hold files only: a nested repository would arrive as an empty pointer. */
|
|
206
|
+
function refuseNestedRepositories(git: string, gitDir: string, tree: string, what: string): void {
|
|
207
|
+
const nested = runGit(git, ['--git-dir', gitDir, 'ls-tree', '-r', tree])
|
|
208
|
+
.split('\n')
|
|
209
|
+
.filter((line) => line.startsWith('160000 '))
|
|
210
|
+
.map((line) => line.split('\t')[1])
|
|
211
|
+
if (nested.length > 0)
|
|
212
|
+
throw new AppInstanceError(
|
|
213
|
+
`${what} holds another git repository (${nested.slice(0, 3).join(', ')}); an archive cannot carry it`,
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Write app `source` (and, when `data` is given, its data) to `file` as one `.acrylblend`. The file is written beside its final name first and renamed
|
|
219
|
+
* over it, so a failed export never leaves a half file under the name the person chose.
|
|
220
|
+
*/
|
|
221
|
+
export function exportArchive(options: {
|
|
222
|
+
readonly git: string
|
|
223
|
+
readonly source: string
|
|
224
|
+
readonly data?: string
|
|
225
|
+
readonly cache: string
|
|
226
|
+
readonly file: string
|
|
227
|
+
}): void {
|
|
228
|
+
const { git, source, file } = options
|
|
229
|
+
const sourceGit = join(source, '.git')
|
|
230
|
+
if (!existsSync(sourceGit)) throw new AppInstanceError(`${source} is not a git repository`)
|
|
231
|
+
withScratch(options.cache, (scratch) => {
|
|
232
|
+
// A bare repository that reads the source's objects in place: the snapshot objects and refs land here, never in the person's repository.
|
|
233
|
+
const repo = join(scratch, 'export.git')
|
|
234
|
+
runGit(git, ['init', '-q', '--bare', repo])
|
|
235
|
+
writeFileSync(join(repo, 'objects', 'info', 'alternates'), `${join(sourceGit, 'objects')}\n`)
|
|
236
|
+
runGit(git, ['--git-dir', repo, 'fetch', '-q', '--no-tags', sourceGit, '+refs/*:refs/*'])
|
|
237
|
+
const branch = (() => {
|
|
238
|
+
try {
|
|
239
|
+
return runGit(git, ['--git-dir', sourceGit, 'symbolic-ref', '-q', 'HEAD'])
|
|
240
|
+
} catch {
|
|
241
|
+
return undefined
|
|
242
|
+
}
|
|
243
|
+
})()
|
|
244
|
+
const head = runGit(git, ['--git-dir', sourceGit, 'rev-parse', '--verify', 'HEAD'])
|
|
245
|
+
if (branch === undefined)
|
|
246
|
+
runGit(git, ['--git-dir', repo, 'update-ref', '--no-deref', 'HEAD', head])
|
|
247
|
+
else runGit(git, ['--git-dir', repo, 'symbolic-ref', 'HEAD', branch])
|
|
248
|
+
|
|
249
|
+
// The files as the person sees them, through the source's own ignore rules.
|
|
250
|
+
const worktreeIndex = join(scratch, 'worktree.index')
|
|
251
|
+
const inWorktree = { GIT_DIR: repo, GIT_WORK_TREE: source, GIT_INDEX_FILE: worktreeIndex }
|
|
252
|
+
runGit(git, ['read-tree', head], { cwd: source, env: inWorktree })
|
|
253
|
+
runGit(git, ['add', '-A', '--', '.', ...KEY_FILES.map((path) => `:(exclude,glob)${path}`)], {
|
|
254
|
+
cwd: source,
|
|
255
|
+
env: inWorktree,
|
|
256
|
+
})
|
|
257
|
+
const tree = runGit(git, ['write-tree'], { cwd: source, env: inWorktree })
|
|
258
|
+
if (tree !== runGit(git, ['--git-dir', repo, 'rev-parse', `${head}^{tree}`])) {
|
|
259
|
+
refuseNestedRepositories(git, repo, tree, 'the app')
|
|
260
|
+
const commit = runGit(
|
|
261
|
+
git,
|
|
262
|
+
[
|
|
263
|
+
'--git-dir',
|
|
264
|
+
repo,
|
|
265
|
+
'commit-tree',
|
|
266
|
+
tree,
|
|
267
|
+
'-p',
|
|
268
|
+
head,
|
|
269
|
+
'-m',
|
|
270
|
+
'The files not yet saved as a version',
|
|
271
|
+
],
|
|
272
|
+
{ env: SNAPSHOT_IDENTITY },
|
|
273
|
+
)
|
|
274
|
+
runGit(git, ['--git-dir', repo, 'update-ref', WORKTREE_REF, commit])
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (options.data !== undefined && existsSync(options.data)) {
|
|
278
|
+
const staged = join(scratch, 'data')
|
|
279
|
+
stageData(options.data, staged, source)
|
|
280
|
+
const inData = {
|
|
281
|
+
GIT_DIR: repo,
|
|
282
|
+
GIT_WORK_TREE: staged,
|
|
283
|
+
GIT_INDEX_FILE: join(scratch, 'data.index'),
|
|
284
|
+
}
|
|
285
|
+
// Every staged file, ignored or not: data has no ignore rules of its own, and the person's global ones are about code.
|
|
286
|
+
runGit(git, ['add', '-A', '--force', '--', '.'], { cwd: staged, env: inData })
|
|
287
|
+
const dataTree = runGit(git, ['write-tree'], { cwd: staged, env: inData })
|
|
288
|
+
refuseNestedRepositories(git, repo, dataTree, "the app's data")
|
|
289
|
+
const commit = runGit(
|
|
290
|
+
git,
|
|
291
|
+
['--git-dir', repo, 'commit-tree', dataTree, '-m', "The app's data"],
|
|
292
|
+
{
|
|
293
|
+
env: SNAPSHOT_IDENTITY,
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
runGit(git, ['--git-dir', repo, 'update-ref', DATA_REF, commit])
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const part = `${file}.part`
|
|
300
|
+
rmSync(part, { force: true })
|
|
301
|
+
runGit(git, ['--git-dir', repo, 'bundle', 'create', '-q', part, '--all'])
|
|
302
|
+
renameSync(part, file)
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The name an imported app gets: the archive's file name without its extension (`My notes.acrylblend` is "My notes"). */
|
|
307
|
+
export function archiveName(file: string): string {
|
|
308
|
+
const name = basename(file)
|
|
309
|
+
return (
|
|
310
|
+
extname(name) === ARCHIVE_EXTENSION ? name.slice(0, -ARCHIVE_EXTENSION.length) : name
|
|
311
|
+
).trim()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The refs a bundle carries and the commit each names, `HEAD` included when it has one. */
|
|
315
|
+
function bundleHeads(git: string, gitDir: string, file: string): Map<string, string> {
|
|
316
|
+
const heads = new Map<string, string>()
|
|
317
|
+
for (const line of runGit(git, ['--git-dir', gitDir, 'bundle', 'list-heads', file]).split('\n')) {
|
|
318
|
+
const [hash, ref] = line.trim().split(/\s+/u)
|
|
319
|
+
if (hash !== undefined && ref !== undefined) heads.set(ref, hash)
|
|
320
|
+
}
|
|
321
|
+
return heads
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Open `file` into an app's empty `source` folder (a fresh repository with every branch and tag, its HEAD on the archive's branch, the unsaved files back
|
|
326
|
+
* as unsaved changes) and, when the archive carries data, into `data`. A file that is not an archive stops before anything is written to the source.
|
|
327
|
+
*/
|
|
328
|
+
export function importArchive(options: {
|
|
329
|
+
readonly git: string
|
|
330
|
+
readonly file: string
|
|
331
|
+
readonly source: string
|
|
332
|
+
readonly data: string
|
|
333
|
+
readonly cache: string
|
|
334
|
+
}): void {
|
|
335
|
+
const { git, file, source } = options
|
|
336
|
+
if (!existsSync(file)) throw new AppInstanceError(`${file} does not exist`)
|
|
337
|
+
withScratch(options.cache, (scratch) => {
|
|
338
|
+
// Read and checked in a scratch repository first: a bundle git cannot verify never touches the source.
|
|
339
|
+
const probe = join(scratch, 'probe.git')
|
|
340
|
+
runGit(git, ['init', '-q', '--bare', probe])
|
|
341
|
+
try {
|
|
342
|
+
runGit(git, ['--git-dir', probe, 'bundle', 'verify', '-q', file])
|
|
343
|
+
} catch {
|
|
344
|
+
throw new AppInstanceError(`${basename(file)} is not an ${ARCHIVE_EXTENSION} archive`)
|
|
345
|
+
}
|
|
346
|
+
const heads = bundleHeads(git, probe, file)
|
|
347
|
+
const branches = [...heads.keys()].filter((ref) => ref.startsWith('refs/heads/'))
|
|
348
|
+
if (branches.length === 0)
|
|
349
|
+
throw new AppInstanceError(`${basename(file)} holds no branch of an app`)
|
|
350
|
+
|
|
351
|
+
runGit(git, ['init', '-q', source])
|
|
352
|
+
const sourceGit = join(source, '.git')
|
|
353
|
+
// The app's own repository behaves the same on every OS (app-storage.md).
|
|
354
|
+
runGit(git, ['--git-dir', sourceGit, 'config', 'core.autocrlf', 'false'])
|
|
355
|
+
runGit(git, ['--git-dir', sourceGit, 'config', 'core.longpaths', 'true'])
|
|
356
|
+
const refspecs = ['+refs/heads/*:refs/heads/*', '+refs/tags/*:refs/tags/*']
|
|
357
|
+
if (heads.has(WORKTREE_REF)) refspecs.push(`+${WORKTREE_REF}:${WORKTREE_REF}`)
|
|
358
|
+
runGit(git, ['--git-dir', sourceGit, 'fetch', '-q', '--update-head-ok', file, ...refspecs])
|
|
359
|
+
|
|
360
|
+
// HEAD on the branch the archive's HEAD named; a detached or missing HEAD takes main, else the first branch.
|
|
361
|
+
const atHead = branches.filter((ref) => heads.get(ref) === heads.get('HEAD'))
|
|
362
|
+
const preferMain = (refs: readonly string[]) =>
|
|
363
|
+
refs.find((ref) => ref === 'refs/heads/main') ?? refs[0]
|
|
364
|
+
const branch = preferMain(atHead) ?? preferMain(branches) ?? 'refs/heads/main'
|
|
365
|
+
runGit(git, ['--git-dir', sourceGit, 'symbolic-ref', 'HEAD', branch])
|
|
366
|
+
runGit(git, ['reset', '-q', '--hard'], { cwd: source })
|
|
367
|
+
if (heads.has(WORKTREE_REF)) {
|
|
368
|
+
// The snapshot's files into the working tree, then the index back to HEAD: they are unsaved changes again, as they were.
|
|
369
|
+
runGit(git, ['read-tree', '-u', '--reset', WORKTREE_REF], { cwd: source })
|
|
370
|
+
runGit(git, ['reset', '-q'], { cwd: source })
|
|
371
|
+
runGit(git, ['--git-dir', sourceGit, 'update-ref', '-d', WORKTREE_REF])
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (heads.has(DATA_REF)) {
|
|
375
|
+
const dataRepo = join(scratch, 'data.git')
|
|
376
|
+
runGit(git, ['init', '-q', '--bare', dataRepo])
|
|
377
|
+
runGit(git, ['--git-dir', dataRepo, 'fetch', '-q', file, `+${DATA_REF}:${DATA_REF}`])
|
|
378
|
+
mkdirSync(options.data, { recursive: true })
|
|
379
|
+
runGit(git, ['read-tree', '-u', '--reset', DATA_REF], {
|
|
380
|
+
cwd: options.data,
|
|
381
|
+
env: {
|
|
382
|
+
GIT_DIR: dataRepo,
|
|
383
|
+
GIT_WORK_TREE: options.data,
|
|
384
|
+
GIT_INDEX_FILE: join(scratch, 'data.index'),
|
|
385
|
+
},
|
|
386
|
+
})
|
|
387
|
+
// The data names this app's own folder, never the one it was exported from.
|
|
388
|
+
const json = JSON.stringify(source).slice(1, -1)
|
|
389
|
+
rewriteText(options.data, [
|
|
390
|
+
[SOURCE_MARK_JSON, json],
|
|
391
|
+
[SOURCE_MARK, source],
|
|
392
|
+
])
|
|
393
|
+
}
|
|
394
|
+
})
|
|
395
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frame host's first preload in a desktop product (`--import`, before the node server's): it registers `bundle.ts`'s resolver, so the Program
|
|
3
|
+
* resolves from the bundle and the person's files from the app's source. A no-op in an app made with `acrylblends init`, which is one folder.
|
|
4
|
+
*/
|
|
5
|
+
import { registerHooks } from 'node:module'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { appTree, overlayResolve } from './bundle.ts'
|
|
8
|
+
|
|
9
|
+
const tree = appTree(process.env, fileURLToPath(new URL('../../', import.meta.url)))
|
|
10
|
+
if (tree !== undefined) registerHooks({ resolve: overlayResolve(tree) })
|