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
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The Program and the person's source as one app tree (specs/049-framework-architecture/app-storage.md in ACRYL, slice 3). A desktop product ships the
3
+ * Program in its bundle, read-only and replaced by every update: the framework's plugins, the generated host and the frame's packages. An app's
4
+ * `source` holds only what is the person's. Their files still name the framework the way every app does (`../plugins/acryl-router/src/index.ts`,
5
+ * `acrylblends`), so the module resolver serves the tree from both places: a Program path from the bundle, anything else from the source.
6
+ *
7
+ * Framework packages resolve from the bundle FIRST, for any importer outside it: a package the person added that carries its own `acrylblends` or
8
+ * `@deepseek-ai/cordis` still gets the bundle's, because two framework copies in one process break it (the `matchedRoutes` 500). Any other package
9
+ * resolves as Node resolves it, from the source's own `node_modules` (what the person added), with the bundle's as the fallback.
10
+ *
11
+ * `NODE_PATH` does not reach ESM; the seam is `registerHooks`, which covers `import`, `require` and `require.resolve` alike. `bundle-overlay.ts`
12
+ * registers it, first, in a product's frame host. Pure apart from `readProduct`.
13
+ */
14
+ import { readFileSync, realpathSync } from 'node:fs'
15
+ import { createRequire, type registerHooks } from 'node:module'
16
+ import { join } from 'node:path'
17
+ import { pathToFileURL } from 'node:url'
18
+ import { ROOT_ENV } from './index.ts'
19
+
20
+ /** The product marker at the bundle root: which product it is, its first app's name, and which paths of the app tree are the Program. */
21
+ export interface Product {
22
+ /** The product's name: its root folder in the OS per-user data folder (`ACRYL Blends`). */
23
+ readonly product: string
24
+ /** The name the first app gets, until the person names it (`Blank`). */
25
+ readonly appName: string
26
+ /** The Program's paths in the app tree, `/`-separated and relative; a folder ends in `/` (`node_modules/`, `plugins/acryl-router/`, `cordis.yml`). */
27
+ readonly program: readonly string[]
28
+ /**
29
+ * A folder of the bundle that holds the Blend this product is: its first app is made from it (the person's part of it) instead of from the bundle itself.
30
+ * `/`-separated, relative, no `.` or `..` segment. Absent in a product that ships the starting app as its own tree (the Blank product).
31
+ */
32
+ readonly seed?: string
33
+ }
34
+
35
+ export const PRODUCT_FILE = 'product.json'
36
+
37
+ /** The product marker at `bundle`, or undefined for an app made with `acrylblends init`. One reader for the file: the desktop shell and the resolver. */
38
+ export function readProduct(bundle: string): Product | undefined {
39
+ try {
40
+ const value: unknown = JSON.parse(readFileSync(join(bundle, PRODUCT_FILE), 'utf8'))
41
+ if (value === null || typeof value !== 'object') return undefined
42
+ const { product, appName, program, seed } = value as {
43
+ product?: unknown
44
+ appName?: unknown
45
+ program?: unknown
46
+ seed?: unknown
47
+ }
48
+ if (typeof product !== 'string' || product.trim() === '') return undefined
49
+ return {
50
+ product: product.trim(),
51
+ appName:
52
+ typeof appName === 'string' && appName.trim() !== '' ? appName.trim() : product.trim(),
53
+ program: Array.isArray(program)
54
+ ? program.filter((path): path is string => isProgramPath(path))
55
+ : [],
56
+ ...(isProgramPath(seed) ? { seed: seed.replace(/\/$/u, '') } : {}),
57
+ }
58
+ } catch {
59
+ return undefined
60
+ }
61
+ }
62
+
63
+ /** A Program path as product.json may name it: relative, `/`-separated, no `.` or `..` segment. Anything else is ignored, never followed. */
64
+ function isProgramPath(path: unknown): path is string {
65
+ return (
66
+ typeof path === 'string' &&
67
+ path !== '' &&
68
+ !path.startsWith('/') &&
69
+ !path.includes('\\') &&
70
+ path
71
+ .replace(/\/$/u, '')
72
+ .split('/')
73
+ .every((segment) => segment !== '' && segment !== '.' && segment !== '..')
74
+ )
75
+ }
76
+
77
+ /** Whether a path of the app tree (relative, `/`-separated) is the Program's. */
78
+ export function inProgram(program: readonly string[], path: string): boolean {
79
+ return program.some((entry) => (entry.endsWith('/') ? path.startsWith(entry) : path === entry))
80
+ }
81
+
82
+ /**
83
+ * The framework's package names: its own (`acrylblends`, the frame's `acryl-*` and `@acrylblends/*`, the brand row's `dsh-*`), the engine's
84
+ * (`@deepseek-ai/*`), and the shared ones the frame is built from (`@acryl/*`, `@webboxes/*`). One copy each per process, the bundle's.
85
+ */
86
+ const FRAMEWORK_PACKAGE =
87
+ /^(?:acrylblends(?:\/|$)|acryl-|dsh-|@acrylblends\/|@deepseek-ai\/|@acryl\/|@webboxes\/)/u
88
+
89
+ export function isFrameworkPackage(specifier: string): boolean {
90
+ return FRAMEWORK_PACKAGE.test(specifier)
91
+ }
92
+
93
+ /** The two halves of one app tree, as `file:` URLs ending in `/`. */
94
+ export interface AppTree {
95
+ /** The bundle: the Program, read-only. */
96
+ readonly bundle: string
97
+ /** The app's source: the person's. */
98
+ readonly source: string
99
+ readonly program: readonly string[]
100
+ }
101
+
102
+ /** A folder as Node names the modules in it: its real path (macOS's temporary folders sit behind `/var` -> `/private/var`), as a `file:` URL ending in `/`. */
103
+ const folderUrl = (path: string): string => {
104
+ let real = path
105
+ try {
106
+ real = realpathSync(path)
107
+ } catch {
108
+ // A folder that does not exist yet keeps its name: nothing in it can be imported anyway.
109
+ }
110
+ const href = pathToFileURL(real).href
111
+ return href.endsWith('/') ? href : `${href}/`
112
+ }
113
+
114
+ /** The tree of a product's frame host: the bundle this module sits in, the source `ACRYLBLENDS_ROOT` names. Undefined outside a product. */
115
+ export function appTree(
116
+ env: Readonly<Record<string, string | undefined>>,
117
+ bundle: string,
118
+ ): AppTree | undefined {
119
+ const root = env[ROOT_ENV]?.trim()
120
+ const product = readProduct(bundle)
121
+ if (root === undefined || root === '' || product === undefined) return undefined
122
+ const tree = { bundle: folderUrl(bundle), source: folderUrl(root), program: product.program }
123
+ return tree.bundle === tree.source ? undefined : tree
124
+ }
125
+
126
+ /** Split a `file:` URL of the tree into its half and its relative path (decoded, `/`-separated), keeping any query or hash for the way back. */
127
+ function locate(
128
+ url: string,
129
+ tree: AppTree,
130
+ ): { half: 'bundle' | 'source'; path: string; suffix: string } | undefined {
131
+ const cut = url.search(/[?#]/u)
132
+ const bare = cut === -1 ? url : url.slice(0, cut)
133
+ const suffix = cut === -1 ? '' : url.slice(cut)
134
+ for (const half of ['bundle', 'source'] as const) {
135
+ const base = tree[half]
136
+ if (bare.startsWith(base))
137
+ return { half, path: decodeURIComponent(bare.slice(base.length)), suffix }
138
+ }
139
+ return undefined
140
+ }
141
+
142
+ /**
143
+ * Where a file of the app tree is served from: a Program path from the bundle, any other path from the source (a bundle file importing `../src/app.ts`
144
+ * gets the person's). A URL outside the tree is returned as it is.
145
+ */
146
+ export function servedFrom(url: string, tree: AppTree): string {
147
+ const found = locate(url, tree)
148
+ if (found === undefined) return url
149
+ const half = inProgram(tree.program, found.path) ? 'bundle' : 'source'
150
+ if (half === found.half) return url
151
+ return `${new URL(found.path.split('/').map(encodeURIComponent).join('/'), tree[half]).href}${found.suffix}`
152
+ }
153
+
154
+ /** What Node answers when the source has no such package or import: the cases the bundle may serve. Any other failure is the module's own. */
155
+ const NOT_THERE = new Set([
156
+ 'ERR_MODULE_NOT_FOUND',
157
+ 'MODULE_NOT_FOUND',
158
+ 'ERR_PACKAGE_IMPORT_NOT_DEFINED',
159
+ ])
160
+
161
+ const isBare = (specifier: string): boolean =>
162
+ !/^(?:\.{1,2}(?:\/|$)|\/|[a-z][a-z0-9+.-]*:)/iu.test(specifier) && !specifier.startsWith('#')
163
+
164
+ type Resolve = Parameters<typeof registerHooks>[0]['resolve'] & {}
165
+
166
+ /** The resolve hook for one tree (exported for the tests; `installBundleOverlay` registers it). */
167
+ export function overlayResolve(tree: AppTree): Resolve {
168
+ const fromBundle = `${tree.bundle}package.json`
169
+ const bundleRequire = createRequire(fromBundle)
170
+ const insideBundle = (parent: string | undefined): boolean =>
171
+ parent !== undefined && locate(parent, tree)?.half === 'bundle'
172
+ /** A bare name as the bundle resolves it. `import` honours a changed parent; `require` resolves from its caller's own paths, so it is asked of the bundle. */
173
+ const resolveInBundle: Resolve = (specifier, context, nextResolve) =>
174
+ context.conditions.includes('require')
175
+ ? nextResolve(bundleRequire.resolve(specifier), context)
176
+ : nextResolve(specifier, { ...context, parentURL: fromBundle })
177
+ return (specifier, context, nextResolve) => {
178
+ // A package name, or a `#` name of the package's import map (the framework's own, `#acryl-jsx/jsx-runtime`, is the bundle manifest's).
179
+ if (isBare(specifier) || specifier.startsWith('#')) {
180
+ if (insideBundle(context.parentURL)) return nextResolve(specifier, context)
181
+ // The framework: the bundle's copy, whoever imports it.
182
+ if (isFrameworkPackage(specifier)) return resolveInBundle(specifier, context, nextResolve)
183
+ try {
184
+ return nextResolve(specifier, context)
185
+ } catch (error) {
186
+ // Anything else the person's files use and their source does not have: the bundle's packages and import map serve it.
187
+ if (!NOT_THERE.has((error as { code?: string }).code ?? '')) throw error
188
+ return resolveInBundle(specifier, context, nextResolve)
189
+ }
190
+ }
191
+ // A path of the tree: the half it belongs to, decided before Node checks that the file exists.
192
+ let target: string
193
+ try {
194
+ target = new URL(specifier, context.parentURL).href
195
+ } catch {
196
+ return nextResolve(specifier, context)
197
+ }
198
+ const served = servedFrom(target, tree)
199
+ return nextResolve(served === target ? specifier : served, context)
200
+ }
201
+ }
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Where an app's data lives, decided in this one file (specs/049-framework-architecture/app-storage.md, task T026).
3
+ *
4
+ * The root is `~/.acryl` on every OS (`acrylRoot`). An app's data is `~/.acryl/apps/<id>/data/`, whether the app is installed (its source is in
5
+ * `apps/<id>/source/`) or is a folder made by `acryl new` (its source is that folder). The id of a folder app is written once into
6
+ * `<app>/.acryl/app.json`, which is git-ignored: a moved folder keeps its id and so its data, and a git clone has none, so it gets its own.
7
+ *
8
+ * Nothing else computes this: the plugin, the generated host and the CLI import it. `scripts/lib/bulkhead.test.mjs` fails when another file does.
9
+ * Only `node:` modules are imported here, so the CLI can bundle this file and an app can carry it.
10
+ */
11
+ import { randomBytes } from 'node:crypto'
12
+ import {
13
+ cpSync,
14
+ existsSync,
15
+ lstatSync,
16
+ mkdirSync,
17
+ readdirSync,
18
+ readFileSync,
19
+ renameSync,
20
+ rmSync,
21
+ symlinkSync,
22
+ writeFileSync,
23
+ } from 'node:fs'
24
+ import { homedir } from 'node:os'
25
+ import { basename, join, posix, resolve, win32 } from 'node:path'
26
+
27
+ export class AppInstanceError extends Error {
28
+ override name = 'AppInstanceError'
29
+ }
30
+
31
+ export const APPS_DIR = 'apps'
32
+ /** The app folder's own state (the door's address file, the app id). Git-ignored by every template. */
33
+ export const APP_STATE_DIR = '.acryl'
34
+ export const APP_ID_FILE = 'app.json'
35
+ /** Where an app made before T026 kept its data, beside its code. */
36
+ export const OLD_HOME_DIR = '.acrylblends'
37
+
38
+ type Env = Readonly<Record<string, string | undefined>>
39
+
40
+ /** The person's home folder: the one place the product asks the OS for it. `HOME` (or `USERPROFILE` on Windows) wins, so an isolated run moves it. */
41
+ export function personHome(
42
+ env: Env = process.env,
43
+ platform: NodeJS.Platform = process.platform,
44
+ ): string {
45
+ const fromEnv = (platform === 'win32' ? env.USERPROFILE : env.HOME)?.trim()
46
+ return fromEnv !== undefined && fromEnv !== '' ? fromEnv : homedir()
47
+ }
48
+
49
+ /** The path module of a platform, so a root computed for Windows on a Mac is a Windows path. */
50
+ export const pathOf = (platform: NodeJS.Platform) => (platform === 'win32' ? win32 : posix)
51
+
52
+ /**
53
+ * The root of everything ACRYL keeps for one person: `<home>/.acryl`, one short, predictable folder on every OS, as DeepSeek Harness keeps `<home>/.dsh`.
54
+ * It is deliberately not the OS's application-data folder (Library/Application Support, AppData, .local/share): those differ per OS, hide the work, and
55
+ * are not what a person types or finds. `ACRYL_HOME`, when it is an absolute path, moves the root (an isolated run, a test), as it moves ACRYL's own home.
56
+ */
57
+ export function acrylRoot(platform: NodeJS.Platform, env: Env, home: string): string {
58
+ const path = pathOf(platform)
59
+ const pinned = env.ACRYL_HOME?.trim()
60
+ return pinned !== undefined && path.isAbsolute(pinned)
61
+ ? path.normalize(pinned)
62
+ : path.join(home, '.acryl')
63
+ }
64
+
65
+ /** Windows refuses these as a file or folder name, whatever the case and whatever follows a dot. */
66
+ const RESERVED = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(\..*)?$/iu
67
+
68
+ /** A control character (U+0000 to U+001F): never in a file name on Windows, never in a one-line label. */
69
+ export const hasControlCharacter = (text: string): boolean =>
70
+ [...text].some((character) => (character.codePointAt(0) ?? 0) < 0x20)
71
+
72
+ /** A name every OS accepts as one path segment: no separators or characters Windows forbids, no trailing dot or space, not reserved, not too long. */
73
+ export function isPortableName(name: string, maxLength: number): boolean {
74
+ return (
75
+ name.length > 0 &&
76
+ name.length <= maxLength &&
77
+ name.trim() === name &&
78
+ !name.endsWith('.') &&
79
+ !/[<>:"/\\|?*]/u.test(name) &&
80
+ !hasControlCharacter(name) &&
81
+ !RESERVED.test(name)
82
+ )
83
+ }
84
+
85
+ /** An app id: lower-case ASCII letters, digits and single dashes, 40 characters at most, never reserved. Compared as is (always lower-case). */
86
+ export function isAppId(id: string): boolean {
87
+ return /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(id) && isPortableName(id, 40)
88
+ }
89
+
90
+ /**
91
+ * A new app's id: the name, folded to ASCII lower-case and dashes, cut to 30 characters, then 4 random hex digits (`my-notes-3f2a`). The name stays a label;
92
+ * renaming the app never moves its folder.
93
+ */
94
+ export function newAppId(
95
+ name: string,
96
+ random: () => string = () => randomBytes(2).toString('hex'),
97
+ ): string {
98
+ const base =
99
+ name
100
+ .normalize('NFKD')
101
+ .replace(/[̀-ͯ]/gu, '')
102
+ .toLowerCase()
103
+ .replace(/[^a-z0-9]+/gu, '-')
104
+ .slice(0, 30)
105
+ .replace(/^-+|-+$/gu, '') || 'app'
106
+ const id = `${base}-${random()}`
107
+ if (!isAppId(id))
108
+ throw new AppInstanceError(`could not make an app id from "${name}" (got "${id}")`)
109
+ return id
110
+ }
111
+
112
+ /** The id of an app folder, when it has one: `<app>/.acryl/app.json` holding `{ "id": "<id>" }` with a valid id. Never creates anything. */
113
+ export function readAppId(appRoot: string): string | undefined {
114
+ try {
115
+ const id = (
116
+ JSON.parse(readFileSync(join(appRoot, APP_STATE_DIR, APP_ID_FILE), 'utf8')) as {
117
+ id?: unknown
118
+ }
119
+ ).id
120
+ return typeof id === 'string' && isAppId(id) ? id : undefined
121
+ } catch {
122
+ return undefined
123
+ }
124
+ }
125
+
126
+ /**
127
+ * The id of an app folder: the one in `.acryl/app.json`, else a new one (the folder's name and four hex digits) written there. Two processes starting a
128
+ * fresh folder at once end with the same id: the file is created exclusively and the loser reads the winner's.
129
+ */
130
+ export function appIdOf(appRoot: string, random?: () => string): string {
131
+ const existing = readAppId(appRoot)
132
+ if (existing !== undefined) return existing
133
+ const id = newAppId(basename(resolve(appRoot)), random)
134
+ const folder = join(appRoot, APP_STATE_DIR)
135
+ try {
136
+ mkdirSync(folder, { recursive: true })
137
+ writeFileSync(join(folder, APP_ID_FILE), `${JSON.stringify({ id }, null, 2)}\n`, { flag: 'wx' })
138
+ return id
139
+ } catch (cause) {
140
+ const raced = readAppId(appRoot)
141
+ if (raced !== undefined) return raced
142
+ throw new AppInstanceError(
143
+ `cannot write the app's id into ${folder}: ${cause instanceof Error ? cause.message : String(cause)}`,
144
+ )
145
+ }
146
+ }
147
+
148
+ /** The folders an app keeps under the root: its data, and what can be rebuilt (`cache`, Chromium's files). Built, never checked. */
149
+ export interface AppHomes {
150
+ readonly id: string
151
+ readonly data: string
152
+ readonly cache: string
153
+ }
154
+
155
+ export function appHomesOf(id: string, root: string, platform: NodeJS.Platform): AppHomes {
156
+ if (!isAppId(id)) throw new AppInstanceError(`"${id}" is not an app id`)
157
+ const folder = pathOf(platform).join(root, APPS_DIR, id)
158
+ return Object.freeze({
159
+ id,
160
+ data: pathOf(platform).join(folder, 'data'),
161
+ cache: pathOf(platform).join(folder, 'cache'),
162
+ })
163
+ }
164
+
165
+ /**
166
+ * The folders of an app made by `acryl new`: `~/.acryl/apps/<id>/data` for its data, `~/.acryl/apps/<id>/cache` for what can be rebuilt. The id is read
167
+ * from (or first written to) the app folder's `.acryl/app.json`. An app that still has a `.acrylblends/` folder beside its code is moved by
168
+ * `migrateOldHome`, which the host runs once at start.
169
+ */
170
+ export function appHomes(
171
+ appRoot: string,
172
+ platform: NodeJS.Platform = process.platform,
173
+ env: Env = process.env,
174
+ home: string = personHome(env, platform),
175
+ ): AppHomes {
176
+ return appHomesOf(appIdOf(appRoot), acrylRoot(platform, env, home), platform)
177
+ }
178
+
179
+ /** The data folder of an app made by `acryl new`: the one answer every part of the app and the CLI shares. */
180
+ export function appDataHome(
181
+ appRoot: string,
182
+ platform: NodeJS.Platform = process.platform,
183
+ env: Env = process.env,
184
+ home: string = personHome(env, platform),
185
+ ): string {
186
+ return appHomes(appRoot, platform, env, home).data
187
+ }
188
+
189
+ /** `{ path: size }` of every file under a folder, relative with `/`: what two copies must agree on. A symbolic link counts as a file, never followed. */
190
+ function inventory(folder: string, prefix = ''): Map<string, number> {
191
+ const found = new Map<string, number>()
192
+ for (const entry of readdirSync(join(folder, prefix), { withFileTypes: true })) {
193
+ const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`
194
+ if (entry.isDirectory()) {
195
+ found.set(`${relative}/`, 0)
196
+ for (const [path, size] of inventory(folder, relative)) found.set(path, size)
197
+ } else found.set(relative, lstatSync(join(folder, relative)).size)
198
+ }
199
+ return found
200
+ }
201
+
202
+ export type MigrationResult =
203
+ | { readonly status: 'none' }
204
+ | {
205
+ readonly status: 'moved'
206
+ readonly from: string
207
+ readonly kept: string
208
+ readonly files: number
209
+ }
210
+ | { readonly status: 'refused'; readonly reason: string }
211
+
212
+ /**
213
+ * Move an app's data from `<app>/.acrylblends/` to its data folder, once. The old folder is copied, checked file by file (names and sizes), and only
214
+ * then renamed to `.acrylblends.old`; it is never deleted. A data folder that already has content is never touched (the move is refused and nothing
215
+ * changes). When the copy or the check fails, what was copied is removed and the old folder stays exactly as it was.
216
+ * `copy` is the copy step, replaceable so a test can fail half way.
217
+ */
218
+ export function migrateOldHome(
219
+ appRoot: string,
220
+ dataHome: string,
221
+ copy: (from: string, to: string) => void = (from, to) =>
222
+ cpSync(from, to, { recursive: true, errorOnExist: true, force: false, verbatimSymlinks: true }),
223
+ ): MigrationResult {
224
+ const old = join(appRoot, OLD_HOME_DIR)
225
+ if (!existsSync(old) || !lstatSync(old).isDirectory()) return { status: 'none' }
226
+ if (existsSync(dataHome) && readdirSync(dataHome).length > 0)
227
+ return {
228
+ status: 'refused',
229
+ reason: `${dataHome} already has content, so ${old} was not moved; look at both and remove one`,
230
+ }
231
+ const created = !existsSync(dataHome)
232
+ try {
233
+ mkdirSync(dataHome, { recursive: true })
234
+ copy(old, dataHome)
235
+ const before = inventory(old)
236
+ const after = inventory(dataHome)
237
+ for (const [path, size] of before)
238
+ if (after.get(path) !== size)
239
+ throw new Error(
240
+ `${path} was not copied whole (${String(after.get(path))} of ${String(size)})`,
241
+ )
242
+ if (after.size !== before.size) throw new Error('the copy has files the original does not')
243
+ } catch (cause) {
244
+ // Only what this call put into the (empty) data folder is removed; the old folder was never changed.
245
+ for (const entry of readdirSync(dataHome))
246
+ rmSync(join(dataHome, entry), { recursive: true, force: true })
247
+ if (created) rmSync(dataHome, { recursive: true, force: true })
248
+ throw new AppInstanceError(
249
+ `could not move ${old} to ${dataHome}: ${cause instanceof Error ? cause.message : String(cause)}; the old folder is unchanged`,
250
+ )
251
+ }
252
+ let kept = `${old}.old`
253
+ for (let n = 2; existsSync(kept); n += 1) kept = `${old}.old-${String(n)}`
254
+ renameSync(old, kept)
255
+ return {
256
+ status: 'moved',
257
+ from: old,
258
+ kept,
259
+ files: [...inventory(dataHome).keys()].filter((p) => !p.endsWith('/')).length,
260
+ }
261
+ }
262
+
263
+ const isLink = (path: string): boolean => {
264
+ try {
265
+ return lstatSync(path).isSymbolicLink()
266
+ } catch {
267
+ return false
268
+ }
269
+ }
270
+
271
+ /**
272
+ * DSH's folder for the chats of one working folder (`dsh-session-persistence-jsonl`, `projectKey`): separators become one dash, safe characters stay, the
273
+ * rest is `~XXXX`, between two dashes pairs, cut to 251. Copied so a chat folder can be renamed with the app's; keep it in step with that function.
274
+ */
275
+ export function chatKeyOf(folder: string): string {
276
+ let readable = ''
277
+ let separatorRun = false
278
+ for (let i = 0; i < folder.length; i += 1) {
279
+ const code = folder.charCodeAt(i)
280
+ const character = String.fromCharCode(code)
281
+ if (character === '/' || character === '\\' || character === ':') {
282
+ if (!separatorRun) readable += '-'
283
+ separatorRun = true
284
+ } else if (character !== '~' && /^[A-Za-z0-9._-]$/u.test(character)) {
285
+ readable += character
286
+ separatorRun = false
287
+ } else {
288
+ readable += `~${code.toString(16).toUpperCase().padStart(4, '0')}`
289
+ separatorRun = false
290
+ }
291
+ }
292
+ return `--${(readable.replace(/^-+/u, '') || 'root').slice(0, 251)}--`
293
+ }
294
+
295
+ /** What {@link moveChats} changed: how many of the three places that name a chat folder were rewritten. */
296
+ export interface ChatMove {
297
+ readonly sessions: boolean
298
+ readonly workspaces: number
299
+ readonly branchChats: boolean
300
+ }
301
+
302
+ function rewriteJson(file: string, change: (value: unknown) => boolean): boolean {
303
+ if (!existsSync(file)) return false
304
+ let value: unknown
305
+ try {
306
+ value = JSON.parse(readFileSync(file, 'utf8'))
307
+ } catch {
308
+ return false
309
+ }
310
+ if (!change(value)) return false
311
+ const temporary = `${file}.${String(process.pid)}.tmp`
312
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`)
313
+ renameSync(temporary, file)
314
+ return true
315
+ }
316
+
317
+ /**
318
+ * Make the chats an app filed under one folder belong to another, once: the engine's folder for the new place becomes a link to the folder the chats are in, the
319
+ * workspace the engine registered keeps its id and its chats under the new path, and the frame's branch chats move to the new key. For an app that was moved
320
+ * or renamed ({@link followFolder}). The chats stay where they are: the engine checks that a chat's log is at the place its own header names
321
+ * (its working folder then), so a renamed folder is refused as corrupt and takes the whole workspace down with it. A chat made later is filed under the new place,
322
+ * in the same folder. A chat made before the move still has the old folder as its working folder. Nothing is overwritten: a folder already at the new key is left alone, and an app with no chat under `from` changes nothing. Run before the engine starts.
323
+ */
324
+ export function moveChats(dataHome: string, from: string, to: string): ChatMove {
325
+ const none: ChatMove = { sessions: false, workspaces: 0, branchChats: false }
326
+ if (from === to) return none
327
+ let sessions = false
328
+ const oldKey = join(dataHome, 'sessions', chatKeyOf(from))
329
+ const newKey = join(dataHome, 'sessions', chatKeyOf(to))
330
+ if (existsSync(oldKey) && !isLink(oldKey) && !existsSync(newKey) && !isLink(newKey)) {
331
+ // A junction (Windows, no privilege needed) wants an absolute target; elsewhere the link is relative, so a moved data root keeps it.
332
+ symlinkSync(
333
+ process.platform === 'win32' ? oldKey : chatKeyOf(from),
334
+ newKey,
335
+ process.platform === 'win32' ? 'junction' : 'dir',
336
+ )
337
+ sessions = true
338
+ }
339
+ let workspaces = 0
340
+ rewriteJson(join(dataHome, 'storages', 'workspace.json'), (value) => {
341
+ const table = (value as { tables?: { workspaces?: Record<string, { path?: unknown }> } } | null)
342
+ ?.tables?.workspaces
343
+ for (const entry of Object.values(table ?? {}))
344
+ if (entry.path === from) {
345
+ entry.path = to
346
+ workspaces += 1
347
+ }
348
+ return workspaces > 0
349
+ })
350
+ const branchChats = rewriteJson(join(dataHome, 'frame', 'branch-chats.json'), (value) => {
351
+ const file = value as { roots?: Record<string, unknown>; open?: Record<string, unknown> } | null
352
+ let changed = false
353
+ for (const table of [file?.roots, file?.open])
354
+ if (table !== undefined && Object.hasOwn(table, from) && !Object.hasOwn(table, to)) {
355
+ table[to] = table[from]
356
+ delete table[from]
357
+ changed = true
358
+ }
359
+ return changed
360
+ })
361
+ return { sessions, workspaces, branchChats }
362
+ }
363
+
364
+ /** Where an app's data home records the folder the app was last started in, so a start in another folder is a move. */
365
+ export const APP_FOLDER_FILE = 'app-folder.json'
366
+
367
+ /**
368
+ * The app was started in another folder than last time (it was moved or renamed): its chats follow it ({@link moveChats} from the folder it was in to the one it is in).
369
+ * Records the folder it is in now. The engine files a chat under the folder it works in, so a moved app would otherwise show no chats. Run before the engine starts.
370
+ * The first start only records. @returns what was changed, none when the folder is the same or unknown.
371
+ */
372
+ export function followFolder(dataHome: string, appRoot: string): ChatMove | undefined {
373
+ const file = join(dataHome, APP_FOLDER_FILE)
374
+ let before: string | undefined
375
+ try {
376
+ const recorded = (JSON.parse(readFileSync(file, 'utf8')) as { root?: unknown }).root
377
+ before = typeof recorded === 'string' && recorded !== '' ? recorded : undefined
378
+ } catch {
379
+ before = undefined
380
+ }
381
+ if (before === appRoot) return undefined
382
+ const moved = before === undefined ? undefined : moveChats(dataHome, before, appRoot)
383
+ mkdirSync(dataHome, { recursive: true })
384
+ writeFileSync(file, `${JSON.stringify({ root: appRoot }, null, 2)}\n`)
385
+ return moved
386
+ }