acrylblends 1.3.2 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/dist/index.d.ts +8 -5
- package/dist/index.js +97 -38
- package/dist/index.js.map +1 -1
- package/legacy/README.md +1 -1
- package/legacy/agent/46c6eadf9230/src/app.ts +293 -0
- package/legacy/agent/46c6eadf9230/src/routes.ts +163 -0
- package/legacy/layouts.json +26 -0
- package/lib/{client-modules-Bkdr83jO.js → client-modules-C5qiVroz.js} +1 -1
- package/lib/cordis/door.d.ts +27 -6
- package/lib/cordis/door.js +41 -10
- package/lib/node-server/register.js +6 -1
- package/lib/node-server.js +1 -1
- package/lib/{transpile-QUK9b3eG.js → transpile-CRRaixyS.js} +19 -4
- package/package.json +1 -1
- package/plugins/acryl-door/src/cli/acryl.ts +13 -4
- package/plugins/acryl-door/src/host/door.ts +41 -3
- package/plugins/acryl-door/src/host/verbs.ts +3 -3
- package/plugins/acryl-node-server/src/register.ts +10 -1
- package/plugins/acryl-node-server/src/transpile.ts +25 -6
- package/plugins/acryl-page/src/favicon.ts +19 -0
- package/plugins/acryl-page/src/index.ts +18 -13
- package/plugins/agent-panel/client/main.ts +9 -0
- package/plugins/agent-panel/headless.ts +2 -2
- package/plugins/desktop/app-page.ts +151 -0
- package/plugins/desktop/assets/acrylblends-logo.png +0 -0
- package/plugins/desktop/brand.ts +164 -0
- package/plugins/desktop/frame.ts +88 -7
- package/plugins/desktop/git.ts +44 -0
- package/plugins/desktop/host-supervisor.ts +63 -0
- package/plugins/desktop/launch.ts +17 -1
- package/plugins/desktop/main.ts +268 -17
- package/plugins/desktop/preload.cjs +8 -0
- package/plugins/desktop/product.ts +117 -0
- package/plugins/desktop/window.ts +4 -0
- package/plugins/desktop-web/main.ts +3 -2
- package/plugins/desktop-web/plugin.json +1 -1
- package/plugins/extensions/index.ts +64 -13
- package/plugins/instance/archive.ts +395 -0
- package/plugins/instance/bundle-overlay.ts +10 -0
- package/plugins/instance/bundle.ts +201 -0
- package/plugins/instance/home.ts +386 -0
- package/plugins/instance/index.ts +132 -24
- package/plugins/instance/storage.ts +413 -0
- package/plugins/plugin-share/index.ts +99 -1
- package/plugins/plugin-share/publish.ts +17 -3
- package/plugins/plugin-share/pull-check.ts +2 -0
- package/plugins/plugin-share/pull-list.ts +71 -0
- package/plugins/showcase/cards.ts +162 -0
- package/plugins/showcase/index.ts +116 -0
- package/plugins/showcase/plugin.json +11 -0
- package/shared/skills/acryl-remove-plugin/SKILL.md +1 -1
- package/templates/agent/AGENTS.md +4 -0
- package/templates/agent/README.md +1 -1
- package/templates/agent/blend.yaml +3 -0
- package/templates/agent/gitignore +2 -0
- package/templates/agent/src/app.ts +2 -0
- package/templates/agent/src/routes.ts +23 -5
- package/templates/agent/template.json +1 -0
- package/templates/agent/test/app.test.ts +26 -0
- package/templates/blank/README.md +1 -1
- package/templates/blank/gitignore +2 -0
- package/templates/blank/src/routes.ts +9 -5
- package/templates/chat/gitignore +2 -0
- package/templates/chat/src/chat-app.ts +7 -3
- package/templates/chat/src/llm.ts +7 -1
- package/templates/chat/test/chat.test.ts +16 -1
- package/templates/nodejs/gitignore +2 -0
- package/templates/notes/gitignore +2 -0
- package/templates/todo/README.md +1 -1
- package/templates/todo/gitignore +2 -0
|
@@ -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
|
+
}
|