acrylblends 1.3.2 → 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.
- package/README.md +5 -2
- package/dist/index.d.ts +8 -5
- package/dist/index.js +97 -38
- 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/layouts.json +26 -0
- package/lib/{client-modules-Bkdr83jO.js → client-modules-C5qiVroz.js} +1 -1
- package/lib/cordis/door.d.ts +27 -6
- package/lib/cordis/door.js +41 -10
- 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 +13 -4
- package/plugins/acryl-door/src/host/door.ts +41 -3
- package/plugins/acryl-door/src/host/verbs.ts +3 -3
- 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 +64 -13
- 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 +386 -0
- package/plugins/instance/index.ts +132 -24
- package/plugins/instance/storage.ts +413 -0
- package/plugins/plugin-share/index.ts +99 -1
- package/plugins/plugin-share/publish.ts +17 -3
- package/plugins/plugin-share/pull-check.ts +2 -0
- package/plugins/plugin-share/pull-list.ts +71 -0
- package/plugins/showcase/cards.ts +162 -0
- package/plugins/showcase/index.ts +116 -0
- package/plugins/showcase/plugin.json +11 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +1 -1
- package/templates/agent/AGENTS.md +4 -0
- package/templates/agent/README.md +1 -1
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/gitignore +2 -0
- package/templates/agent/src/app.ts +2 -0
- package/templates/agent/src/routes.ts +23 -5
- package/templates/agent/template.json +1 -0
- package/templates/agent/test/app.test.ts +26 -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'
|
|
@@ -116,6 +116,8 @@ export interface ExtensionInfo {
|
|
|
116
116
|
readonly missing: readonly string[]
|
|
117
117
|
/** The source folder no longer exists; the last installed version still runs. */
|
|
118
118
|
readonly stale: boolean
|
|
119
|
+
/** The routes its mount added, as the plugin wrote them (without the frame's prefix), so a surface can link to its pages. */
|
|
120
|
+
readonly routes: readonly { readonly method: string; readonly path: string }[]
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
export type InstallResult =
|
|
@@ -214,6 +216,22 @@ const inside = (path: string, folder: string) => {
|
|
|
214
216
|
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
|
|
215
217
|
}
|
|
216
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
|
+
|
|
217
235
|
function injected(module: PluginModule): string[] {
|
|
218
236
|
const inject = module.inject
|
|
219
237
|
if (Array.isArray(inject))
|
|
@@ -257,6 +275,9 @@ export class Extensions extends Service {
|
|
|
257
275
|
private readonly owner: Context
|
|
258
276
|
private readonly store: PluginStore
|
|
259
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
|
|
260
281
|
|
|
261
282
|
constructor(ctx: Context, config: ExtensionsConfig, store: PluginStore) {
|
|
262
283
|
super(ctx, 'extensions')
|
|
@@ -268,6 +289,8 @@ export class Extensions extends Service {
|
|
|
268
289
|
this.rows = new Set(config.rows ?? [])
|
|
269
290
|
this.say = config.log ?? ((line) => console.log(line))
|
|
270
291
|
const { home, root } = ctx.appInstance
|
|
292
|
+
this.appRoot = root
|
|
293
|
+
this.dataHome = home
|
|
271
294
|
this.sources = (config.sources ?? [{ dir: join(home, 'extensions'), trust: 'install' }]).map(
|
|
272
295
|
(source) => ({
|
|
273
296
|
dir: resolve(source.dir),
|
|
@@ -353,6 +376,7 @@ export class Extensions extends Service {
|
|
|
353
376
|
state,
|
|
354
377
|
missing: state === 'pending' ? this.missing(entry.module) : [],
|
|
355
378
|
stale: this.stale.has(entry.name),
|
|
379
|
+
routes: entry.routes.map(({ method, path }) => ({ method, path: this.written(path) })),
|
|
356
380
|
}
|
|
357
381
|
})
|
|
358
382
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
@@ -609,6 +633,13 @@ export class Extensions extends Service {
|
|
|
609
633
|
return servedRoutes(fibers)
|
|
610
634
|
}
|
|
611
635
|
|
|
636
|
+
/** A served path as the plugin wrote it: without the frame's canvas prefix. */
|
|
637
|
+
private written(path: string): string {
|
|
638
|
+
return this.routePrefix !== '' && path.startsWith(`${this.routePrefix}/`)
|
|
639
|
+
? path.slice(this.routePrefix.length)
|
|
640
|
+
: path
|
|
641
|
+
}
|
|
642
|
+
|
|
612
643
|
/**
|
|
613
644
|
* A route the new plugin added that another plugin already serves: the router would answer with the other one, so the new route would never be
|
|
614
645
|
* reached. Refused before the plugin's own check, which the other plugin's answer would pass.
|
|
@@ -624,15 +655,11 @@ export class Extensions extends Service {
|
|
|
624
655
|
[...this.installed.values()].find((entry) =>
|
|
625
656
|
entry.routes.some((own) => own.method === taken.method && own.path === taken.path),
|
|
626
657
|
)?.name ?? (taken.owner === '' ? 'the app' : taken.owner)
|
|
627
|
-
const shown = (path: string): string =>
|
|
628
|
-
this.routePrefix !== '' && path.startsWith(`${this.routePrefix}/`)
|
|
629
|
-
? path.slice(this.routePrefix.length)
|
|
630
|
-
: path
|
|
631
658
|
const same =
|
|
632
|
-
taken.path === route.path ? '' : ` (it serves ${taken.method} ${
|
|
659
|
+
taken.path === route.path ? '' : ` (it serves ${taken.method} ${this.written(taken.path)})`
|
|
633
660
|
return {
|
|
634
661
|
stage: 'conflict',
|
|
635
|
-
error: `${route.method} ${
|
|
662
|
+
error: `${route.method} ${this.written(route.path)} is already served by ${owner}${same}; rename this plugin's route, or remove ${owner} first`,
|
|
636
663
|
}
|
|
637
664
|
}
|
|
638
665
|
|
|
@@ -654,7 +681,11 @@ export class Extensions extends Service {
|
|
|
654
681
|
private saveState(): void {
|
|
655
682
|
const records: Record<string, Record_> = {}
|
|
656
683
|
for (const entry of this.installed.values())
|
|
657
|
-
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
|
+
}
|
|
658
689
|
mkdirSync(join(this.stateFile, '..'), { recursive: true })
|
|
659
690
|
const temporary = `${this.stateFile}.${String(process.pid)}.tmp`
|
|
660
691
|
writeFileSync(temporary, `${JSON.stringify(records, null, 2)}\n`)
|
|
@@ -851,6 +882,21 @@ export class Extensions extends Service {
|
|
|
851
882
|
return summary
|
|
852
883
|
}
|
|
853
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
|
+
|
|
854
900
|
private async startup(): Promise<StartupSummary> {
|
|
855
901
|
if (this.readOnly) return this.startupReadOnly()
|
|
856
902
|
const summary: StartupSummary = { installed: [], pending: [], stale: [], failed: [] }
|
|
@@ -861,21 +907,26 @@ export class Extensions extends Service {
|
|
|
861
907
|
} catch (error) {
|
|
862
908
|
summary.failed.push({ name: 'extensions.json', error: message(error) })
|
|
863
909
|
}
|
|
864
|
-
for (const [name,
|
|
910
|
+
for (const [name, stored] of Object.entries(records)) {
|
|
865
911
|
// Switched off: not started, and dropped from the record; switched on again, its folder is found and installed as new.
|
|
866
912
|
if (this.off.has(name)) continue
|
|
867
913
|
// Now a row of blend.yaml: the app starts it, and the record of its install is dropped.
|
|
868
914
|
if (this.rows.has(name)) continue
|
|
915
|
+
const record = this.resolved(name, stored)
|
|
869
916
|
try {
|
|
870
917
|
// What was installed before was approved then: an unchanged source starts again without asking.
|
|
871
918
|
this.approved.set(name, record.version)
|
|
872
919
|
if (existsSync(record.source)) {
|
|
873
920
|
const result = await this.installNow(record.source)
|
|
874
|
-
if (result.ok)
|
|
875
|
-
|
|
876
|
-
|
|
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
|
|
877
928
|
}
|
|
878
|
-
// 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.
|
|
879
930
|
summary.stale.push(name)
|
|
880
931
|
this.stale.add(name)
|
|
881
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) })
|