acrylblends 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +5 -2
  2. package/dist/index.d.ts +7 -4
  3. package/dist/index.js +191 -62
  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/blank/a53d41e6ef42/src/app.ts +160 -0
  9. package/legacy/blank/a53d41e6ef42/src/routes.ts +79 -0
  10. package/legacy/layouts.json +30 -0
  11. package/lib/{client-modules-Bkdr83jO.js → client-modules-C5qiVroz.js} +1 -1
  12. package/lib/cordis/door.js +1 -1
  13. package/lib/node-server/register.js +6 -1
  14. package/lib/node-server.js +1 -1
  15. package/lib/{transpile-QUK9b3eG.js → transpile-CRRaixyS.js} +19 -4
  16. package/package.json +1 -1
  17. package/plugins/acryl-door/src/cli/acryl.ts +6 -2
  18. package/plugins/acryl-node-server/src/register.ts +10 -1
  19. package/plugins/acryl-node-server/src/transpile.ts +25 -6
  20. package/plugins/acryl-page/src/favicon.ts +19 -0
  21. package/plugins/acryl-page/src/index.ts +18 -13
  22. package/plugins/agent-panel/client/main.ts +9 -0
  23. package/plugins/agent-panel/headless.ts +2 -2
  24. package/plugins/desktop/app-page.ts +151 -0
  25. package/plugins/desktop/assets/acrylblends-logo.png +0 -0
  26. package/plugins/desktop/brand.ts +164 -0
  27. package/plugins/desktop/frame.ts +88 -7
  28. package/plugins/desktop/git.ts +44 -0
  29. package/plugins/desktop/host-supervisor.ts +63 -0
  30. package/plugins/desktop/launch.ts +17 -1
  31. package/plugins/desktop/main.ts +268 -17
  32. package/plugins/desktop/preload.cjs +8 -0
  33. package/plugins/desktop/product.ts +117 -0
  34. package/plugins/desktop/window.ts +4 -0
  35. package/plugins/desktop-web/main.ts +3 -2
  36. package/plugins/desktop-web/plugin.json +1 -1
  37. package/plugins/extensions/index.ts +52 -7
  38. package/plugins/instance/archive.ts +395 -0
  39. package/plugins/instance/bundle-overlay.ts +10 -0
  40. package/plugins/instance/bundle.ts +201 -0
  41. package/plugins/instance/home.ts +395 -0
  42. package/plugins/instance/index.ts +132 -24
  43. package/plugins/instance/storage.ts +413 -0
  44. package/shared/skills/acryl-remove-plugin/SKILL.md +1 -1
  45. package/templates/agent/README.md +1 -1
  46. package/templates/agent/gitignore +2 -0
  47. package/templates/blank/README.md +1 -1
  48. package/templates/blank/gitignore +2 -0
  49. package/templates/blank/src/routes.ts +9 -5
  50. package/templates/chat/gitignore +2 -0
  51. package/templates/chat/src/chat-app.ts +7 -3
  52. package/templates/chat/src/llm.ts +7 -1
  53. package/templates/chat/test/chat.test.ts +16 -1
  54. package/templates/nodejs/gitignore +2 -0
  55. package/templates/notes/gitignore +2 -0
  56. package/templates/todo/README.md +1 -1
  57. package/templates/todo/gitignore +2 -0
@@ -8,11 +8,23 @@
8
8
  * Pure apart from `selectInstance`, which reads the given environment object. Paths are built, never checked.
9
9
  */
10
10
  import { createHash } from 'node:crypto'
11
+ import { readFileSync } from 'node:fs'
11
12
  import { basename, join, resolve } from 'node:path'
12
13
  import type { Context } from '@deepseek-ai/cordis'
14
+ import {
15
+ AppInstanceError,
16
+ appDataHome,
17
+ appHomes,
18
+ followFolder,
19
+ migrateOldHome,
20
+ moveChats,
21
+ personHome,
22
+ } from './home.ts'
13
23
 
14
- /** How the instance was chosen: from the project folder, or a home the caller pinned. */
15
- export type AppInstanceKind = 'project' | 'pinned'
24
+ export { AppInstanceError, appDataHome, appHomes, followFolder, migrateOldHome, moveChats }
25
+
26
+ /** How the instance was chosen: from the project folder, a home the caller pinned, or an app of a desktop product's root (`storage.ts`). */
27
+ export type AppInstanceKind = 'project' | 'pinned' | 'product'
16
28
 
17
29
  /** Where the app listens first, and whether it moves to the next free port when that one is taken. Port 0 means any free port. */
18
30
  export interface PortPreference {
@@ -24,7 +36,7 @@ export interface AppInstance {
24
36
  readonly kind: AppInstanceKind
25
37
  /** Unique on this machine: seeds the port. */
26
38
  readonly id: string
27
- /** Human name: the project folder's name. */
39
+ /** Human name: the name the app was made with (its package.json), else the project folder's name. */
28
40
  readonly name: string
29
41
  /** The project folder: its code and its `node_modules`. */
30
42
  readonly root: string
@@ -42,21 +54,23 @@ export interface AppInstance {
42
54
  readonly runLockFile: string
43
55
  }
44
56
 
45
- export const HOME_DIR_NAME = '.acrylblends'
46
57
  export const RUN_LOCK_FILE = 'instance.json'
47
58
  export const PORT_BASE = 3100
48
59
  export const PORT_SPAN = 900
49
60
  export const HOME_ENV = 'ACRYLBLENDS_HOME'
61
+ /**
62
+ * The app's source folder, when the process runs from another folder: a desktop product's frame host runs the Program from the bundle on an app's
63
+ * `source` (storage.ts), so its own location is not the app. Set by `instanceEnv`, never by hand.
64
+ */
65
+ export const ROOT_ENV = 'ACRYLBLENDS_ROOT'
50
66
  export const PORT_ENV = 'ACRYLBLENDS_PORT'
67
+ /** The app's name, for a process that runs from the bundle on the app's `source` folder: the folder's own name says nothing (`source`). Set by `instanceEnv`. */
68
+ export const NAME_ENV = 'ACRYLBLENDS_NAME'
51
69
  export const HOST_ENV = 'ACRYLBLENDS_HOST'
52
70
  /** The names hosting platforms set: used when `ACRYLBLENDS_PORT` and `ACRYLBLENDS_HOST` are not. */
53
71
  export const PLATFORM_PORT_ENV = 'PORT'
54
72
  export const PLATFORM_HOST_ENV = 'HOST'
55
73
 
56
- export class AppInstanceError extends Error {
57
- override name = 'AppInstanceError'
58
- }
59
-
60
74
  /** FNV-1a over the id: stable across runs and platforms, spread over the app port range, so two apps rarely start on the same port. */
61
75
  export function stablePort(id: string): number {
62
76
  let hash = 0x811c9dc5
@@ -75,6 +89,18 @@ const plainName = (folder: string) =>
75
89
  .replace(/[^a-z0-9]+/g, '-')
76
90
  .replace(/^-+|-+$/g, '') || 'app'
77
91
 
92
+ /** The name the app was made with (`init --name`, kept as package.json's name), or undefined when it has none. */
93
+ function declaredName(root: string): string | undefined {
94
+ try {
95
+ const name = (
96
+ JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { name?: unknown }
97
+ ).name
98
+ return typeof name === 'string' && name.trim() !== '' ? name.trim() : undefined
99
+ } catch {
100
+ return undefined
101
+ }
102
+ }
103
+
78
104
  function family(input: {
79
105
  kind: AppInstanceKind
80
106
  id: string
@@ -95,18 +121,49 @@ function family(input: {
95
121
  })
96
122
  }
97
123
 
98
- /** The default: the project folder owns its data in `<project>/.acrylblends`. Two folders with one name get different ids. */
99
- export function projectInstance(projectRoot: string): AppInstance {
124
+ /**
125
+ * The default: a folder made by `acryl new`. Its code is the folder; its data is `~/.acryl/apps/<id>/data` (`home.ts`, the one place that says so), with the
126
+ * id kept in the folder's own `.acryl/app.json`, so the data follows the app when the folder is moved and is not shared with a clone of it.
127
+ * The first call on a folder writes that file, so the folder must exist and be writable; tests pass `env` with `ACRYL_HOME` and `HOME` pointing at a scratch folder.
128
+ */
129
+ export function projectInstance(
130
+ projectRoot: string,
131
+ env: Readonly<Record<string, string | undefined>> = {},
132
+ platform: NodeJS.Platform = process.platform,
133
+ ): AppInstance {
100
134
  const root = resolve(projectRoot)
101
- const name = plainName(root)
102
- const id = `${name}-${digest(root, 4)}`
135
+ const name = declaredName(root) ?? plainName(root)
136
+ const folders = appHomes(root, platform, env, personHome(env, platform))
137
+ // An app made before the data moved still has `<app>/.acrylblends`: it is moved once, here, at start. A move that fails stops the app with the old folder intact.
138
+ const moved = migrateOldHome(root, folders.data)
139
+ if (moved.status === 'moved')
140
+ process.stderr.write(
141
+ `acrylblends: moved this app's data to ${folders.data} (${String(moved.files)} files); the old folder is kept as ${moved.kept}\n`,
142
+ )
143
+ else if (moved.status === 'refused') process.stderr.write(`acrylblends: ${moved.reason}\n`)
144
+ // The folder was moved or renamed since the last start: its chats (filed by the folder they work in) are brought along, once.
145
+ // Never stops the app: chats that could not be attached are left as they were, and the app starts.
146
+ try {
147
+ const followed = followFolder(folders.data, root)
148
+ if (
149
+ followed !== undefined &&
150
+ (followed.sessions || followed.workspaces > 0 || followed.branchChats)
151
+ )
152
+ process.stderr.write(
153
+ `acrylblends: this app's folder moved, so its chats were attached to ${root}\n`,
154
+ )
155
+ } catch (cause) {
156
+ process.stderr.write(
157
+ `acrylblends: could not attach this app's chats to ${root}: ${cause instanceof Error ? cause.message : String(cause)}\n`,
158
+ )
159
+ }
103
160
  return family({
104
161
  kind: 'project',
105
- id,
162
+ id: folders.id,
106
163
  name,
107
164
  root,
108
- home: join(root, HOME_DIR_NAME),
109
- port: { start: stablePort(id), scan: true },
165
+ home: folders.data,
166
+ port: { start: stablePort(folders.id), scan: true },
110
167
  })
111
168
  }
112
169
 
@@ -124,13 +181,31 @@ export function pinnedInstance(
124
181
  return family({
125
182
  kind: 'pinned',
126
183
  id,
127
- name: options.name ?? plainName(root),
184
+ name: options.name ?? declaredName(root) ?? plainName(root),
128
185
  root,
129
186
  home: absolute,
130
187
  port: { start: stablePort(id), scan: true },
131
188
  })
132
189
  }
133
190
 
191
+ /**
192
+ * An app of a desktop product (`storage.ts`): its source is the project folder, its data the home (the engine's home too, so its logs land in
193
+ * `data/logs`), and its id, the registry's, seeds the port. The name is the person's label for it.
194
+ */
195
+ export function productInstance(
196
+ folders: { readonly id: string; readonly source: string; readonly data: string },
197
+ name: string,
198
+ ): AppInstance {
199
+ return family({
200
+ kind: 'product',
201
+ id: folders.id,
202
+ name,
203
+ root: folders.source,
204
+ home: folders.data,
205
+ port: { start: stablePort(folders.id), scan: true },
206
+ })
207
+ }
208
+
134
209
  /** The same family with an explicit start port. Port 0 asks the system for any free port. */
135
210
  export function withPort(instance: AppInstance, start: number): AppInstance {
136
211
  if (!Number.isInteger(start) || start < 0 || start > 65_535) {
@@ -142,23 +217,56 @@ export function withPort(instance: AppInstance, start: number): AppInstance {
142
217
  }
143
218
 
144
219
  /**
145
- * Choose the instance. The only place that reads the environment: `ACRYLBLENDS_HOME` pins the home, `ACRYLBLENDS_PORT` sets the start port, `ACRYLBLENDS_HOST` the interface;
146
- * hosting platforms set `PORT` and `HOST` instead, and those are read when the `ACRYLBLENDS_` ones are not; otherwise the project folder decides.
220
+ * Choose the instance. The only place that reads the environment: `ACRYLBLENDS_ROOT` names the app's folder when the process runs from another one,
221
+ * `ACRYLBLENDS_HOME` pins the home, `ACRYLBLENDS_PORT` sets the start port, `ACRYLBLENDS_HOST` the interface; hosting platforms set `PORT` and `HOST`
222
+ * instead, and those are read when the `ACRYLBLENDS_` ones are not; otherwise the project folder decides.
147
223
  */
148
224
  export function selectInstance(options: {
149
225
  projectRoot: string
150
226
  env?: Record<string, string | undefined>
151
227
  }): AppInstance {
152
228
  const env = options.env ?? {}
229
+ const projectRoot = env[ROOT_ENV]?.trim() || options.projectRoot
153
230
  const pinned = env[HOME_ENV]?.trim()
154
- let instance = pinned
155
- ? pinnedInstance(pinned, { name: plainName(options.projectRoot), root: options.projectRoot })
156
- : projectInstance(options.projectRoot)
231
+ return withRunEnv(
232
+ pinned
233
+ ? pinnedInstance(pinned, {
234
+ name: env[NAME_ENV]?.trim() || declaredName(projectRoot) || plainName(projectRoot),
235
+ root: projectRoot,
236
+ })
237
+ : projectInstance(projectRoot, env),
238
+ env,
239
+ )
240
+ }
241
+
242
+ /**
243
+ * Where an instance listens, as the environment says, whichever way it was chosen (a desktop product's app too): `ACRYLBLENDS_PORT` (else a hosting
244
+ * platform's `PORT`) the start port, `ACRYLBLENDS_HOST` (else `HOST`) the interface.
245
+ */
246
+ export function withRunEnv(
247
+ instance: AppInstance,
248
+ env: Record<string, string | undefined> = {},
249
+ ): AppInstance {
250
+ let chosen = instance
157
251
  const port = env[PORT_ENV]?.trim() || env[PLATFORM_PORT_ENV]?.trim()
158
- if (port) instance = withPort(instance, Number(port))
252
+ if (port) chosen = withPort(chosen, Number(port))
159
253
  const host = env[HOST_ENV]?.trim() || env[PLATFORM_HOST_ENV]?.trim()
160
- if (host) instance = Object.freeze({ ...instance, host })
161
- return instance
254
+ if (host) chosen = Object.freeze({ ...chosen, host })
255
+ return chosen
256
+ }
257
+
258
+ /**
259
+ * What a child process started for this instance (the frame host) needs in its environment so that its own `selectInstance` chooses the same home and
260
+ * the same start port, on the same app folder. The parent decides once; the child never derives a second answer from the folder it runs in.
261
+ */
262
+ export function instanceEnv(instance: AppInstance): Record<string, string> {
263
+ return {
264
+ [ROOT_ENV]: instance.root,
265
+ [NAME_ENV]: instance.name,
266
+ [HOME_ENV]: instance.home,
267
+ [PORT_ENV]: String(instance.port.start),
268
+ ...(instance.host === undefined ? {} : { [HOST_ENV]: instance.host }),
269
+ }
162
270
  }
163
271
 
164
272
  declare module '@deepseek-ai/cordis' {
@@ -0,0 +1,413 @@
1
+ /**
2
+ * Where ACRYL keeps the apps of one person (specs/049-framework-architecture/app-storage.md in ACRYL): one short root, `~/.acryl`, the same on macOS,
3
+ * Windows and Linux (as DeepSeek Harness keeps `~/.dsh`), and one sealed folder per app inside it.
4
+ *
5
+ * <root>/layout.json { "layout": 1, "writtenBy": "<who wrote it and its version>" }
6
+ * <root>/apps.json the registry: id, name, created, last opened
7
+ * <root>/apps/<id>/source/ the person's work: a git repository
8
+ * <root>/apps/<id>/data/ their settings, sessions and notes; data/logs holds the logs (the engine writes `<home>/logs`)
9
+ * <root>/apps/<id>/cache/ build output: rebuilt on demand
10
+ * <root>/electron/ Chromium's own user data
11
+ * <root>/trash/ an app that was started over, moved here, never deleted
12
+ *
13
+ * There is no per-OS rule: `acrylRoot` is `<home>/.acryl` everywhere, so what a person is told, types and finds is one thing. Nothing else in the product
14
+ * reads the home folder or asks Electron for a path (scripts/lib/bulkhead.test.mjs). No file written here holds an absolute path: the root and the bundle can move.
15
+ */
16
+ import {
17
+ type Dirent,
18
+ existsSync,
19
+ mkdirSync,
20
+ readFileSync,
21
+ renameSync,
22
+ rmSync,
23
+ writeFileSync,
24
+ } from 'node:fs'
25
+ import { lstat, readdir } from 'node:fs/promises'
26
+ import { join } from 'node:path'
27
+ import { archiveName, exportArchive, importArchive } from './archive.ts'
28
+ import {
29
+ APPS_DIR,
30
+ AppInstanceError,
31
+ acrylRoot,
32
+ hasControlCharacter,
33
+ isAppId,
34
+ isPortableName,
35
+ newAppId,
36
+ pathOf,
37
+ personHome,
38
+ } from './home.ts'
39
+ import { type AppInstance, productInstance } from './index.ts'
40
+
41
+ // The rule for where things are lives in home.ts; these are re-exported so the product code and its tests keep one import.
42
+ export { APPS_DIR, acrylRoot, isAppId, isPortableName, newAppId, personHome }
43
+
44
+ /** The layout this build writes and reads. A root with a lower one is migrated on open (a later slice); a higher one is refused. */
45
+ export const LAYOUT_VERSION = 1
46
+ export const LAYOUT_FILE = 'layout.json'
47
+ export const REGISTRY_FILE = 'apps.json'
48
+ export const TRASH_DIR = 'trash'
49
+ export const ELECTRON_DIR = 'electron'
50
+
51
+ /** One app's folders. `logs` is inside `data` because the engine writes its logs to `<home>/logs`. */
52
+ export interface ProductAppFolders {
53
+ readonly id: string
54
+ readonly source: string
55
+ readonly data: string
56
+ readonly cache: string
57
+ readonly logs: string
58
+ }
59
+
60
+ /** The folders of app `id` under `root`, built, never checked. */
61
+ export function appFolders(
62
+ root: string,
63
+ id: string,
64
+ platform: NodeJS.Platform = process.platform,
65
+ ): ProductAppFolders {
66
+ if (!isAppId(id)) throw new AppInstanceError(`"${id}" is not an app id`)
67
+ const path = pathOf(platform)
68
+ const folder = path.join(root, APPS_DIR, id)
69
+ const data = path.join(folder, 'data')
70
+ return Object.freeze({
71
+ id,
72
+ source: path.join(folder, 'source'),
73
+ data,
74
+ cache: path.join(folder, 'cache'),
75
+ logs: path.join(data, 'logs'),
76
+ })
77
+ }
78
+
79
+ /** One app in the registry. Dates are ISO strings; nothing here is a path. */
80
+ export interface RegisteredApp {
81
+ readonly id: string
82
+ readonly name: string
83
+ readonly created: string
84
+ readonly lastOpened?: string
85
+ /** The person chose the name (named it on the first start, renamed it, or imported it under a file name); absent while it is the product's default. */
86
+ readonly named?: true
87
+ }
88
+
89
+ /** What `openRoot` found: a root this build writes, or one written by a newer build (refused by the caller with the version it names). */
90
+ export type RootState =
91
+ | { readonly kind: 'current' }
92
+ | { readonly kind: 'older'; readonly layout: number }
93
+ | { readonly kind: 'newer'; readonly layout: number; readonly writtenBy: string }
94
+
95
+ /** A JSON file written whole: to a sibling first, then renamed over, so a crash never leaves half a file. */
96
+ function writeJson(file: string, value: unknown): void {
97
+ const next = `${file}.next`
98
+ writeFileSync(next, `${JSON.stringify(value, null, 2)}\n`)
99
+ renameSync(next, file)
100
+ }
101
+
102
+ function readJson(file: string): unknown {
103
+ return JSON.parse(readFileSync(file, 'utf8')) as unknown
104
+ }
105
+
106
+ /**
107
+ * Open a product root, creating it on the first start: `layout.json` and an empty `apps.json`. A root written by a newer layout is reported, never
108
+ * changed; an older one is reported for the migration runner. A layout file that is not this format stops with a message, never a guess.
109
+ */
110
+ export function openRoot(root: string, writtenBy: string): RootState {
111
+ const file = join(root, LAYOUT_FILE)
112
+ if (!existsSync(file)) {
113
+ mkdirSync(root, { recursive: true })
114
+ writeJson(join(root, REGISTRY_FILE), { apps: [] })
115
+ writeJson(file, { layout: LAYOUT_VERSION, writtenBy })
116
+ return { kind: 'current' }
117
+ }
118
+ const value = readJson(file) as { layout?: unknown; writtenBy?: unknown }
119
+ if (typeof value.layout !== 'number' || !Number.isInteger(value.layout) || value.layout < 1)
120
+ throw new AppInstanceError(`${file} has no layout version; it was not written by this product`)
121
+ if (value.layout > LAYOUT_VERSION)
122
+ return {
123
+ kind: 'newer',
124
+ layout: value.layout,
125
+ writtenBy: typeof value.writtenBy === 'string' ? value.writtenBy : 'a newer version',
126
+ }
127
+ if (value.layout < LAYOUT_VERSION) return { kind: 'older', layout: value.layout }
128
+ return { kind: 'current' }
129
+ }
130
+
131
+ /** The apps the root holds, in the order they were made. */
132
+ export function listApps(root: string): RegisteredApp[] {
133
+ const value = readJson(join(root, REGISTRY_FILE)) as { apps?: unknown }
134
+ if (!Array.isArray(value.apps))
135
+ throw new AppInstanceError(`${join(root, REGISTRY_FILE)} has no app list`)
136
+ return value.apps.filter(
137
+ (app): app is RegisteredApp =>
138
+ app !== null &&
139
+ typeof app === 'object' &&
140
+ typeof (app as RegisteredApp).id === 'string' &&
141
+ isAppId((app as RegisteredApp).id) &&
142
+ typeof (app as RegisteredApp).name === 'string',
143
+ )
144
+ }
145
+
146
+ /** One app's registry entry, or a message naming the root. */
147
+ function findApp(root: string, id: string): RegisteredApp {
148
+ const found = listApps(root).find((app) => app.id === id)
149
+ if (found === undefined) throw new AppInstanceError(`no app "${id}" in ${root}`)
150
+ return found
151
+ }
152
+
153
+ function saveApps(root: string, apps: readonly RegisteredApp[]): void {
154
+ writeJson(join(root, REGISTRY_FILE), { apps })
155
+ }
156
+
157
+ /** A name a person gave: trimmed, one line, not empty, 80 characters at most. It is a label only, so any script is fine. */
158
+ function checkedName(name: string): string {
159
+ const trimmed = name.trim()
160
+ if (trimmed === '' || trimmed.length > 80 || hasControlCharacter(trimmed))
161
+ throw new AppInstanceError('an app name is one line of 1 to 80 characters')
162
+ return trimmed
163
+ }
164
+
165
+ /**
166
+ * Make a new app: its id, its four folders, its registry entry. Returns the entry. `seed` fills the new, empty source folder (the product's starting
167
+ * app); the entry is written only after it succeeded, and a failed seed removes the folder it was given, so the registry never names a half-made app.
168
+ */
169
+ export function createApp(
170
+ root: string,
171
+ name: string,
172
+ options: {
173
+ now?: () => Date
174
+ random?: () => string
175
+ seed?: (folders: ProductAppFolders) => void
176
+ /** The person chose this name (an import, a fresh start of a named app). */
177
+ named?: boolean
178
+ } = {},
179
+ ): RegisteredApp {
180
+ const label = checkedName(name)
181
+ const apps = listApps(root)
182
+ const taken = new Set(apps.map((app) => app.id))
183
+ let id = newAppId(label, options.random)
184
+ for (let attempt = 0; taken.has(id) || existsSync(join(root, APPS_DIR, id)); attempt += 1) {
185
+ if (attempt > 16) throw new AppInstanceError(`no free id for "${label}"`)
186
+ id = newAppId(label)
187
+ }
188
+ const folders = appFolders(root, id)
189
+ for (const folder of [folders.source, folders.logs, folders.cache])
190
+ mkdirSync(folder, { recursive: true })
191
+ if (options.seed !== undefined) {
192
+ try {
193
+ options.seed(folders)
194
+ } catch (cause) {
195
+ // The folder was made by this call a moment ago and holds nothing of the person's.
196
+ rmSync(join(root, APPS_DIR, id), { recursive: true, force: true })
197
+ throw cause
198
+ }
199
+ }
200
+ const entry: RegisteredApp = {
201
+ id,
202
+ name: label,
203
+ created: (options.now?.() ?? new Date()).toISOString(),
204
+ ...(options.named === true ? { named: true as const } : {}),
205
+ }
206
+ saveApps(root, [...apps, entry])
207
+ return entry
208
+ }
209
+
210
+ /** Give an app a new name; from then on it is the person's name for it. The folder keeps its id. */
211
+ export function renameApp(root: string, id: string, name: string): RegisteredApp {
212
+ const label = checkedName(name)
213
+ const apps = listApps(root)
214
+ const found = findApp(root, id)
215
+ const renamed: RegisteredApp = { ...found, name: label, named: true }
216
+ saveApps(
217
+ root,
218
+ apps.map((app) => (app.id === id ? renamed : app)),
219
+ )
220
+ return renamed
221
+ }
222
+
223
+ /** Record that an app was opened now. */
224
+ export function touchApp(root: string, id: string, now: () => Date = () => new Date()): void {
225
+ const apps = listApps(root)
226
+ if (!apps.some((app) => app.id === id)) throw new AppInstanceError(`no app "${id}" in ${root}`)
227
+ saveApps(
228
+ root,
229
+ apps.map((app) => (app.id === id ? { ...app, lastOpened: now().toISOString() } : app)),
230
+ )
231
+ }
232
+
233
+ /** What an app's folder holds: its bytes (the files' own sizes, links not followed) and its files. */
234
+ export interface AppSize {
235
+ readonly bytes: number
236
+ readonly files: number
237
+ }
238
+
239
+ /** The size of everything under `folder`, read without blocking: an app with packages of its own has many files. */
240
+ export async function folderSize(folder: string): Promise<AppSize> {
241
+ let bytes = 0
242
+ let files = 0
243
+ const walk = async (dir: string): Promise<void> => {
244
+ let entries: Dirent[]
245
+ try {
246
+ entries = await readdir(dir, { withFileTypes: true })
247
+ } catch {
248
+ return
249
+ }
250
+ for (const entry of entries) {
251
+ const path = join(dir, entry.name)
252
+ if (entry.isDirectory()) await walk(path)
253
+ else {
254
+ try {
255
+ bytes += (await lstat(path)).size
256
+ files += 1
257
+ } catch {
258
+ // Gone while it was read (a log rotated): not counted.
259
+ }
260
+ }
261
+ }
262
+ }
263
+ await walk(folder)
264
+ return { bytes, files }
265
+ }
266
+
267
+ /** A trash folder's name for app `id` moved at `now`: the id and the time, so the same app started over twice keeps both. */
268
+ function trashName(id: string, now: Date): string {
269
+ return `${id}-${now.toISOString().replace(/[:.]/gu, '-')}`
270
+ }
271
+
272
+ /**
273
+ * Start app `id` over: a fresh app with the same name is made first (seeded as the first start is), then the old one leaves the registry and its folder
274
+ * moves to `trash/` with a note of its name, never deleted. In that order a crash never loses the person's work: at worst an extra app, or an old
275
+ * folder left in `apps/` that no entry names. The caller stops everything that runs on the old app's folders first.
276
+ */
277
+ export function resetApp(
278
+ root: string,
279
+ id: string,
280
+ seed: (folders: ProductAppFolders) => void,
281
+ now: () => Date = () => new Date(),
282
+ ): RegisteredApp {
283
+ const old = findApp(root, id)
284
+ const fresh = createApp(root, old.name, { seed, now, named: old.named === true })
285
+ saveApps(
286
+ root,
287
+ listApps(root).filter((app) => app.id !== id),
288
+ )
289
+ const at = now()
290
+ const trash = join(root, TRASH_DIR, trashName(id, at))
291
+ mkdirSync(join(root, TRASH_DIR), { recursive: true })
292
+ renameSync(join(root, APPS_DIR, id), trash)
293
+ writeJson(join(trash, 'trashed.json'), {
294
+ id,
295
+ name: old.name,
296
+ created: old.created,
297
+ trashed: at.toISOString(),
298
+ })
299
+ return fresh
300
+ }
301
+
302
+ /**
303
+ * The port a desktop product keeps its apps through (app-storage.md: one port, the per-OS rule in one adapter at the edge). Callers name an app by its
304
+ * id and get an `AppInstance` back; they never join a path. The frame's Settings > App page reaches it through the desktop shell.
305
+ */
306
+ export interface AppStorage {
307
+ /** Chromium's own user data for the product's windows (window state, its cache): one folder per product, beside the apps. */
308
+ readonly electron: string
309
+ /** The apps the product holds, in the order they were made. */
310
+ apps(): RegisteredApp[]
311
+ /** The app opened last, else the one made last, or undefined while the product holds none. */
312
+ last(): RegisteredApp | undefined
313
+ /** Make an app named `name`; `seed` fills its empty source folder (the product's starting app). */
314
+ create(name: string, seed: (source: string) => void): RegisteredApp
315
+ /** Open app `id`: it is recorded as opened now, and its instance is returned (source the project root, data its home and the engine's). */
316
+ open(id: string): AppInstance
317
+ /** One app's entry. */
318
+ app(id: string): RegisteredApp
319
+ /** Give app `id` the person's name for it. */
320
+ rename(id: string, name: string): RegisteredApp
321
+ /** What app `id`'s folder holds. */
322
+ size(id: string): Promise<AppSize>
323
+ /** Show app `id`'s folder in the OS file manager (Finder, Explorer, Files). */
324
+ reveal(id: string): void
325
+ /** Write app `id` to `file` as one `.acrylblend`: its source with every version, and its data when `data` is set (never its keys or logs). */
326
+ export(id: string, file: string, options: { readonly data: boolean }): void
327
+ /** Make a new app from an `.acrylblend`, named after the file. Nothing is written when the file is not an archive. */
328
+ import(file: string): RegisteredApp
329
+ /** Start app `id` over: a fresh app of the same name, seeded by `seed`, and the old one moved to `trash/`. Stop what runs on it first. */
330
+ reset(id: string, seed: (source: string) => void): RegisteredApp
331
+ }
332
+
333
+ /** A root written by a newer layout, which this build cannot read: the message names the version that wrote it. */
334
+ export class NewerLayoutError extends AppInstanceError {
335
+ override name = 'NewerLayoutError'
336
+ }
337
+
338
+ /**
339
+ * The filesystem adapter of `AppStorage`: the root `~/.acryl` (`acrylRoot`), opened (created on the first start) and
340
+ * checked before anything is read. A root of a newer layout stops with a message, never a half-read start; the layout is 1, so an older one cannot exist
341
+ * yet (the migration runner comes with layout 2).
342
+ */
343
+ export function openProductStorage(options: {
344
+ /** Who writes the root and its version (`ACRYL Blends 1.5.0`), written into a new root's layout.json and named when a newer one is found. */
345
+ readonly writtenBy: string
346
+ readonly platform?: NodeJS.Platform
347
+ readonly env?: Readonly<Record<string, string | undefined>>
348
+ readonly home?: string
349
+ /** The git executable exports and imports run: the product's own, else `git` on the PATH. */
350
+ readonly git?: string
351
+ /** The OS file manager, showing one folder (Electron's `shell.showItemInFolder` in the product). */
352
+ readonly reveal?: (path: string) => void
353
+ }): AppStorage {
354
+ const platform = options.platform ?? process.platform
355
+ const env = options.env ?? process.env
356
+ const root = acrylRoot(platform, env, options.home ?? personHome(env, platform))
357
+ const git = options.git ?? 'git'
358
+ const state = openRoot(root, options.writtenBy)
359
+ if (state.kind !== 'current')
360
+ throw new NewerLayoutError(
361
+ state.kind === 'newer'
362
+ ? `${root} was written by ${state.writtenBy}, which is newer than this one (${options.writtenBy}): install that version or a later one to open it`
363
+ : `${root} has layout ${String(state.layout)}, which this version cannot migrate`,
364
+ )
365
+ const opened = (app: RegisteredApp): string => app.lastOpened ?? app.created
366
+ return {
367
+ electron: join(root, ELECTRON_DIR),
368
+ apps: () => listApps(root),
369
+ last: () =>
370
+ listApps(root).reduce<RegisteredApp | undefined>(
371
+ (latest, app) => (latest === undefined || opened(app) >= opened(latest) ? app : latest),
372
+ undefined,
373
+ ),
374
+ create: (name, seed) => createApp(root, name, { seed: (folders) => seed(folders.source) }),
375
+ open(id) {
376
+ const app = findApp(root, id)
377
+ touchApp(root, id)
378
+ return productInstance(appFolders(root, id, platform), app.name)
379
+ },
380
+ app: (id) => findApp(root, id),
381
+ rename: (id, name) => renameApp(root, id, name),
382
+ size: (id) => folderSize(join(root, APPS_DIR, findApp(root, id).id)),
383
+ reveal(id) {
384
+ if (options.reveal === undefined)
385
+ throw new AppInstanceError('this product cannot show a folder')
386
+ options.reveal(join(root, APPS_DIR, findApp(root, id).id))
387
+ },
388
+ export(id, file, { data }) {
389
+ const folders = appFolders(root, findApp(root, id).id, platform)
390
+ exportArchive({
391
+ git,
392
+ source: folders.source,
393
+ ...(data ? { data: folders.data } : {}),
394
+ cache: folders.cache,
395
+ file,
396
+ })
397
+ },
398
+ import: (file) =>
399
+ createApp(root, archiveName(file) || 'Imported app', {
400
+ named: true,
401
+ seed: (folders) => {
402
+ importArchive({
403
+ git,
404
+ file,
405
+ source: folders.source,
406
+ data: folders.data,
407
+ cache: folders.cache,
408
+ })
409
+ },
410
+ }),
411
+ reset: (id, seed) => resetApp(root, id, (folders) => seed(folders.source)),
412
+ }
413
+ }
@@ -8,5 +8,5 @@ description: Use when the person asks to remove, delete, switch off, hide or und
8
8
 
9
9
  Find the exact plugin in `plugins/` and its row in `blend.yaml` (never guess the name). To hide it for now, switch it off: `disabled: true` on
10
10
  its row (the switch in Settings > Plugins does the same); everything stays and it comes back when switched on. To remove it, and only when the
11
- person clearly asked: delete its row, its folder and its test; its data in `.acrylblends/` stays unless they ask. Check with `npm test` and
11
+ person clearly asked: delete its row, its folder and its test; its data in `~/.acryl/apps/<id>/data/` stays unless they ask. Check with `npm test` and
12
12
  `.acryl/bin/acryl look` that it is gone before you say so. Only touch the plugin the person named.
@@ -5,7 +5,7 @@ Cordis plugin.
5
5
 
6
6
  ```sh
7
7
  npm install
8
- npm run dev # prints its URL; data lives in .acrylblends/; it restarts when a file changes (a new port if ACRYLBLENDS_PORT=0)
8
+ npm run dev # prints its URL; data lives in ~/.acryl/apps/<id>/data/; it restarts when a file changes (a new port if ACRYLBLENDS_PORT=0)
9
9
  npm test
10
10
  npm run check # type-check
11
11
 
@@ -2,3 +2,5 @@ node_modules/
2
2
  .acrylblends/
3
3
  .acryl/
4
4
  package-lock.json
5
+ .env
6
+ .env.*
@@ -5,7 +5,7 @@ Cordis plugin.
5
5
 
6
6
  ```sh
7
7
  npm install
8
- npm run dev # prints its URL; data lives in .acrylblends/
8
+ npm run dev # prints its URL; data lives in ~/.acryl/apps/<id>/data/
9
9
  npm test
10
10
  npm run check # type-check
11
11
  ```