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.
- package/README.md +5 -2
- package/dist/index.d.ts +7 -4
- package/dist/index.js +83 -33
- 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 +15 -0
- package/lib/{client-modules-Bkdr83jO.js → client-modules-C5qiVroz.js} +1 -1
- package/lib/cordis/door.js +1 -1
- package/lib/node-server/register.js +6 -1
- package/lib/node-server.js +1 -1
- package/lib/{transpile-QUK9b3eG.js → transpile-CRRaixyS.js} +19 -4
- package/package.json +1 -1
- package/plugins/acryl-door/src/cli/acryl.ts +6 -2
- package/plugins/acryl-node-server/src/register.ts +10 -1
- package/plugins/acryl-node-server/src/transpile.ts +25 -6
- package/plugins/acryl-page/src/favicon.ts +19 -0
- package/plugins/acryl-page/src/index.ts +18 -13
- package/plugins/agent-panel/client/main.ts +9 -0
- package/plugins/agent-panel/headless.ts +2 -2
- package/plugins/desktop/app-page.ts +151 -0
- package/plugins/desktop/assets/acrylblends-logo.png +0 -0
- package/plugins/desktop/brand.ts +164 -0
- package/plugins/desktop/frame.ts +88 -7
- package/plugins/desktop/git.ts +44 -0
- package/plugins/desktop/host-supervisor.ts +63 -0
- package/plugins/desktop/launch.ts +17 -1
- package/plugins/desktop/main.ts +268 -17
- package/plugins/desktop/preload.cjs +8 -0
- package/plugins/desktop/product.ts +117 -0
- package/plugins/desktop/window.ts +4 -0
- package/plugins/desktop-web/main.ts +3 -2
- package/plugins/desktop-web/plugin.json +1 -1
- package/plugins/extensions/index.ts +52 -7
- package/plugins/instance/archive.ts +395 -0
- package/plugins/instance/bundle-overlay.ts +10 -0
- package/plugins/instance/bundle.ts +201 -0
- package/plugins/instance/home.ts +386 -0
- package/plugins/instance/index.ts +132 -24
- package/plugins/instance/storage.ts +413 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +1 -1
- package/templates/agent/README.md +1 -1
- package/templates/agent/gitignore +2 -0
- package/templates/blank/README.md +1 -1
- package/templates/blank/gitignore +2 -0
- package/templates/blank/src/routes.ts +9 -5
- package/templates/chat/gitignore +2 -0
- package/templates/chat/src/chat-app.ts +7 -3
- package/templates/chat/src/llm.ts +7 -1
- package/templates/chat/test/chat.test.ts +16 -1
- package/templates/nodejs/gitignore +2 -0
- package/templates/notes/gitignore +2 -0
- package/templates/todo/README.md +1 -1
- package/templates/todo/gitignore +2 -0
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
-
/**
|
|
99
|
-
|
|
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
|
|
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:
|
|
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: `
|
|
146
|
-
*
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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)
|
|
252
|
+
if (port) chosen = withPort(chosen, Number(port))
|
|
159
253
|
const host = env[HOST_ENV]?.trim() || env[PLATFORM_HOST_ENV]?.trim()
|
|
160
|
-
if (host)
|
|
161
|
-
return
|
|
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
|
|
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
|
|
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
|
|