acrylblends 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +5 -2
  2. package/dist/index.d.ts +7 -4
  3. package/dist/index.js +83 -33
  4. package/dist/index.js.map +1 -1
  5. package/legacy/README.md +1 -1
  6. package/legacy/agent/46c6eadf9230/src/app.ts +293 -0
  7. package/legacy/agent/46c6eadf9230/src/routes.ts +163 -0
  8. package/legacy/layouts.json +15 -0
  9. package/lib/{client-modules-Bkdr83jO.js → client-modules-C5qiVroz.js} +1 -1
  10. package/lib/cordis/door.js +1 -1
  11. package/lib/node-server/register.js +6 -1
  12. package/lib/node-server.js +1 -1
  13. package/lib/{transpile-QUK9b3eG.js → transpile-CRRaixyS.js} +19 -4
  14. package/package.json +1 -1
  15. package/plugins/acryl-door/src/cli/acryl.ts +6 -2
  16. package/plugins/acryl-node-server/src/register.ts +10 -1
  17. package/plugins/acryl-node-server/src/transpile.ts +25 -6
  18. package/plugins/acryl-page/src/favicon.ts +19 -0
  19. package/plugins/acryl-page/src/index.ts +18 -13
  20. package/plugins/agent-panel/client/main.ts +9 -0
  21. package/plugins/agent-panel/headless.ts +2 -2
  22. package/plugins/desktop/app-page.ts +151 -0
  23. package/plugins/desktop/assets/acrylblends-logo.png +0 -0
  24. package/plugins/desktop/brand.ts +164 -0
  25. package/plugins/desktop/frame.ts +88 -7
  26. package/plugins/desktop/git.ts +44 -0
  27. package/plugins/desktop/host-supervisor.ts +63 -0
  28. package/plugins/desktop/launch.ts +17 -1
  29. package/plugins/desktop/main.ts +268 -17
  30. package/plugins/desktop/preload.cjs +8 -0
  31. package/plugins/desktop/product.ts +117 -0
  32. package/plugins/desktop/window.ts +4 -0
  33. package/plugins/desktop-web/main.ts +3 -2
  34. package/plugins/desktop-web/plugin.json +1 -1
  35. package/plugins/extensions/index.ts +52 -7
  36. package/plugins/instance/archive.ts +395 -0
  37. package/plugins/instance/bundle-overlay.ts +10 -0
  38. package/plugins/instance/bundle.ts +201 -0
  39. package/plugins/instance/home.ts +386 -0
  40. package/plugins/instance/index.ts +132 -24
  41. package/plugins/instance/storage.ts +413 -0
  42. package/shared/skills/acryl-remove-plugin/SKILL.md +1 -1
  43. package/templates/agent/README.md +1 -1
  44. package/templates/agent/gitignore +2 -0
  45. package/templates/blank/README.md +1 -1
  46. package/templates/blank/gitignore +2 -0
  47. package/templates/blank/src/routes.ts +9 -5
  48. package/templates/chat/gitignore +2 -0
  49. package/templates/chat/src/chat-app.ts +7 -3
  50. package/templates/chat/src/llm.ts +7 -1
  51. package/templates/chat/test/chat.test.ts +16 -1
  52. package/templates/nodejs/gitignore +2 -0
  53. package/templates/notes/gitignore +2 -0
  54. package/templates/todo/README.md +1 -1
  55. 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] = { source: entry.source, version: entry.version, dir: entry.dir }
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, record] of Object.entries(records)) {
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) summary.installed.push(name)
881
- else summary.failed.push({ name, error: result.errors.join('; ') })
882
- continue
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) })