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
package/plugins/desktop/frame.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The desktop window on the ACRYL frame (docs/plans/ide-host-inversion.md, section 27). An app with `scripts/ide-host.mjs` runs inside the frame on `npm run dev`;
|
|
3
|
-
* `npm run desktop` shows that same frame: it attaches to a host that already runs (the host's address record,
|
|
3
|
+
* `npm run desktop` shows that same frame: it attaches to a host that already runs (the host's address record, `frame-url.json` in the app's data folder), or starts the host
|
|
4
4
|
* the way `npm run dev` does and loads the address it prints. Nothing here imports electron: `main.ts` decides with these functions and owns the window.
|
|
5
5
|
*/
|
|
6
6
|
import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs'
|
|
8
8
|
import { connect } from 'node:net'
|
|
9
9
|
import { join, resolve } from 'node:path'
|
|
10
|
+
import { pathToFileURL } from 'node:url'
|
|
11
|
+
import { appDataHome } from '../instance/index.ts'
|
|
10
12
|
|
|
11
13
|
/** The launcher every frame app has; without it the app runs on its own server (made before the frame). */
|
|
12
14
|
export const HOST_SCRIPT = join('scripts', 'ide-host.mjs')
|
|
@@ -23,11 +25,11 @@ export function isFrameApp(root: string, exists: (path: string) => boolean = exi
|
|
|
23
25
|
return exists(join(root, HOST_SCRIPT))
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
/** The app home the host uses: `ACRYLBLENDS_HOME` when set, else
|
|
28
|
+
/** The app home the host uses: `ACRYLBLENDS_HOME` when set, else the app's data folder under ~/.acryl (`appDataHome`, the one place that decides it). */
|
|
27
29
|
export function frameHome(root: string, env: Record<string, string | undefined>): string {
|
|
28
30
|
const pinned = env.ACRYLBLENDS_HOME?.trim()
|
|
29
31
|
return pinned === undefined || pinned === ''
|
|
30
|
-
?
|
|
32
|
+
? appDataHome(resolve(root), process.platform, env)
|
|
31
33
|
: resolve(pinned)
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -147,25 +149,93 @@ export function hostCommand(
|
|
|
147
149
|
: { file: 'npm', args: ['run', 'dev'], shell: false }
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
/** How a host ended: its exit, the last of its output, and whether the window asked for it (`stop`) or it ended by itself. */
|
|
153
|
+
export interface HostEnd {
|
|
154
|
+
readonly code: number | null
|
|
155
|
+
readonly signal: NodeJS.Signals | null
|
|
156
|
+
readonly tail: string
|
|
157
|
+
readonly requested: boolean
|
|
158
|
+
}
|
|
159
|
+
|
|
150
160
|
export interface FrameHost {
|
|
151
161
|
/** The frame address the host printed, or a rejection with the host's output when it ended first. */
|
|
152
162
|
readonly ready: Promise<string>
|
|
163
|
+
/** Resolves once, when the host has ended, whenever that is: after it served the frame too (a stop it did not get asked for is a death). */
|
|
164
|
+
readonly ended: Promise<HostEnd>
|
|
153
165
|
/** Stop the host and what it started; resolves once it has exited. */
|
|
154
166
|
stop(): Promise<void>
|
|
155
167
|
}
|
|
156
168
|
|
|
169
|
+
export interface FrameHostLaunch {
|
|
170
|
+
/**
|
|
171
|
+
* A packaged product has no npm to run `npm run dev` with: the host runs on the app's own Electron runtime as Node, with the same loader the dev script uses.
|
|
172
|
+
*/
|
|
173
|
+
readonly runtime?: string
|
|
174
|
+
/**
|
|
175
|
+
* Where the Program is when it is not in the app folder: a product's bundle. The host, its loader and the resolver that serves the Program from there
|
|
176
|
+
* and the person's files from the app's source (plugins/instance/bundle-overlay.ts) start from it; the host still runs in the app folder.
|
|
177
|
+
*/
|
|
178
|
+
readonly program?: string
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* What a packaged host watches: the person's own files that make the app, and nothing else. `node --watch` alone follows only the modules the host has loaded,
|
|
183
|
+
* so a new plugin folder, or a row added to `blend.yaml`, stayed unseen until some loaded file changed (found on a packed app: a plugin written into
|
|
184
|
+
* `plugins/notes/` was not served after 15 s). Naming a path turns the module watching off, so the folders are named whole; `.git` (a commit must not restart
|
|
185
|
+
* the app) and the app's data are not among them.
|
|
186
|
+
*/
|
|
187
|
+
export const WATCHED_IN_SOURCE = ['src', 'plugins', 'blend.yaml'] as const
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The arguments of a packaged product's host (the runtime is Electron as Node): `--watch` first, as the app's `dev` script has it, so a saved change in the
|
|
191
|
+
* person's source or plugins restarts the host and the app shows it, with no extensions plugin in the frame to do it (found on a packed app: an edit of
|
|
192
|
+
* `src/routes.ts` stayed unseen until the app was quit). Then the loader and, when the Program is in a bundle, the overlay that serves it.
|
|
193
|
+
*/
|
|
194
|
+
export function packagedHostArgs(
|
|
195
|
+
program: string,
|
|
196
|
+
root: string,
|
|
197
|
+
exists: (path: string) => boolean = existsSync,
|
|
198
|
+
): string[] {
|
|
199
|
+
return [
|
|
200
|
+
'--watch',
|
|
201
|
+
...WATCHED_IN_SOURCE.map((name) => join(root, name))
|
|
202
|
+
.filter((path) => exists(path))
|
|
203
|
+
.map((path) => `--watch-path=${path}`),
|
|
204
|
+
// The loader first and the overlay second: a hook registered later runs first, so the overlay sees every import before the loader's aliases.
|
|
205
|
+
'--import',
|
|
206
|
+
pathToFileURL(join(program, 'plugins', 'acryl-node-server', 'src', 'register.ts')).href,
|
|
207
|
+
...(program === root
|
|
208
|
+
? []
|
|
209
|
+
: [
|
|
210
|
+
'--import',
|
|
211
|
+
pathToFileURL(join(program, 'plugins', 'instance', 'bundle-overlay.ts')).href,
|
|
212
|
+
]),
|
|
213
|
+
join(program, HOST_SCRIPT),
|
|
214
|
+
]
|
|
215
|
+
}
|
|
216
|
+
|
|
157
217
|
/** Start the app's frame host as `npm run dev` does, in the app folder; its output is passed through to `write`. */
|
|
158
218
|
export function startFrameHost(
|
|
159
219
|
root: string,
|
|
160
220
|
env: NodeJS.ProcessEnv,
|
|
161
221
|
write: (text: string) => void,
|
|
162
222
|
platform: NodeJS.Platform = process.platform,
|
|
223
|
+
launch: FrameHostLaunch = {},
|
|
163
224
|
): FrameHost {
|
|
164
|
-
const
|
|
225
|
+
const program = launch.program ?? root
|
|
226
|
+
const command =
|
|
227
|
+
launch.runtime === undefined
|
|
228
|
+
? hostCommand(env, platform)
|
|
229
|
+
: { file: launch.runtime, args: packagedHostArgs(program, root), shell: false }
|
|
165
230
|
// The host leads its own process group on macOS and Linux, so a stop reaches npm, the host and every terminal it runs; Windows stops the tree with taskkill.
|
|
166
231
|
const child: ChildProcess = spawn(command.file, command.args, {
|
|
167
232
|
cwd: root,
|
|
168
|
-
env: {
|
|
233
|
+
env: {
|
|
234
|
+
...env,
|
|
235
|
+
...(launch.runtime === undefined
|
|
236
|
+
? { ELECTRON_RUN_AS_NODE: undefined }
|
|
237
|
+
: { ELECTRON_RUN_AS_NODE: '1' }),
|
|
238
|
+
},
|
|
169
239
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
170
240
|
shell: command.shell,
|
|
171
241
|
detached: platform !== 'win32',
|
|
@@ -173,16 +243,21 @@ export function startFrameHost(
|
|
|
173
243
|
})
|
|
174
244
|
let output = ''
|
|
175
245
|
let exited = false
|
|
246
|
+
let requested = false
|
|
247
|
+
let outcome: Pick<HostEnd, 'code' | 'signal'> = { code: null, signal: null }
|
|
176
248
|
const exit = new Promise<void>((done) => {
|
|
177
|
-
child.once('exit', () => {
|
|
249
|
+
child.once('exit', (code, signal) => {
|
|
178
250
|
exited = true
|
|
251
|
+
outcome = { code, signal }
|
|
179
252
|
done()
|
|
180
253
|
})
|
|
181
|
-
child.once('error', () => {
|
|
254
|
+
child.once('error', (cause) => {
|
|
182
255
|
exited = true
|
|
256
|
+
output += `\n${cause.message}`
|
|
183
257
|
done()
|
|
184
258
|
})
|
|
185
259
|
})
|
|
260
|
+
const ended = exit.then((): HostEnd => ({ ...outcome, tail: output.slice(-3000), requested }))
|
|
186
261
|
const ready = new Promise<string>((done, fail) => {
|
|
187
262
|
const take = (chunk: Buffer) => {
|
|
188
263
|
const text = chunk.toString()
|
|
@@ -213,9 +288,15 @@ export function startFrameHost(
|
|
|
213
288
|
// The group is already gone.
|
|
214
289
|
}
|
|
215
290
|
}
|
|
291
|
+
// What the host left running (its terminals, the watcher's child) does not outlive a host that ended by itself.
|
|
292
|
+
void exit.then(() => {
|
|
293
|
+
if (!requested) signal('SIGTERM')
|
|
294
|
+
})
|
|
216
295
|
return {
|
|
217
296
|
ready,
|
|
297
|
+
ended,
|
|
218
298
|
async stop() {
|
|
299
|
+
requested = true
|
|
219
300
|
if (exited) return
|
|
220
301
|
signal('SIGTERM')
|
|
221
302
|
const graceful = await Promise.race([
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The git a desktop product ships (desktop/dugite-native, packed by scripts/pack-blank-mac.mjs into `Resources/git`, beside the app). A clean Mac has only
|
|
3
|
+
* the shim that asks to install the Command Line Tools, and Windows has no git, while versions, export, the terminals and every agent run `git`. The
|
|
4
|
+
* frame host gets it first on its PATH, so everything it starts (versions, the workspace's git, terminals, the chat's shell, PTY agents) finds the same git
|
|
5
|
+
* by name. Outside a product, or in a product packed without it, the person's own git is used as before.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
export interface ProductGit {
|
|
12
|
+
/** The executable to run (on macOS and Linux a wrapper that finds git's helpers from where it lies). */
|
|
13
|
+
readonly executable: string
|
|
14
|
+
/** The folder to put first on PATH. */
|
|
15
|
+
readonly bin: string
|
|
16
|
+
/** Git's licence and the offer of its source, shown from Settings > App. */
|
|
17
|
+
readonly licence: string
|
|
18
|
+
readonly source: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The product's git under `resources` (Electron's `process.resourcesPath`), or undefined when the product carries none. */
|
|
22
|
+
export function productGit(resources: string, platform: NodeJS.Platform): ProductGit | undefined {
|
|
23
|
+
const dir = join(resources, 'git')
|
|
24
|
+
// dugite-native's Windows build is MinGit: `cmd/git.exe` finds its own helpers.
|
|
25
|
+
const bin = join(dir, platform === 'win32' ? 'cmd' : 'bin')
|
|
26
|
+
const executable = join(bin, platform === 'win32' ? 'git.exe' : 'git')
|
|
27
|
+
if (!existsSync(executable)) return undefined
|
|
28
|
+
return { executable, bin, licence: join(dir, 'COPYING.txt'), source: join(dir, 'SOURCE.txt') }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `env` with the product's git first on its PATH (Windows names the variable `Path`, in any case; the existing name is kept). */
|
|
32
|
+
export function withGitFirst(
|
|
33
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
34
|
+
git: ProductGit,
|
|
35
|
+
platform: NodeJS.Platform,
|
|
36
|
+
): Record<string, string | undefined> {
|
|
37
|
+
const key =
|
|
38
|
+
platform === 'win32'
|
|
39
|
+
? (Object.keys(env).find((name) => name.toUpperCase() === 'PATH') ?? 'Path')
|
|
40
|
+
: 'PATH'
|
|
41
|
+
const separator = platform === 'win32' ? ';' : ':'
|
|
42
|
+
const rest = (env[key] ?? '').split(separator).filter((dir) => dir !== '' && dir !== git.bin)
|
|
43
|
+
return { ...env, [key]: [git.bin, ...rest].join(separator) }
|
|
44
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the window does when its frame host stops by itself (found on an installed app 2026-11-09: the host had ended days before, nothing restarted it, and
|
|
3
|
+
* the window kept asking a closed port, a thousand errors and a dead page). Two small rules, both pure of Electron so they are tested without a window:
|
|
4
|
+
* when to start the host again, and where its output is kept so the next stop has a cause on disk.
|
|
5
|
+
*/
|
|
6
|
+
import { appendFileSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
export interface RestartPolicy {
|
|
10
|
+
/** Stops inside `windowMs` that are forgiven; one more and the host is not started again by itself. */
|
|
11
|
+
readonly maxInWindow: number
|
|
12
|
+
readonly windowMs: number
|
|
13
|
+
/** The wait before the n-th restart inside the window: short first, then longer. */
|
|
14
|
+
readonly delaysMs: readonly number[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const RESTART_POLICY: RestartPolicy = Object.freeze({
|
|
18
|
+
maxInWindow: 4,
|
|
19
|
+
windowMs: 120_000,
|
|
20
|
+
delaysMs: [500, 2_000, 5_000, 10_000],
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export type RestartDecision = { restart: true; delayMs: number } | { restart: false }
|
|
24
|
+
|
|
25
|
+
/** `stops` are the times (ms) the host stopped by itself, oldest or newest first; those older than the window are forgotten. */
|
|
26
|
+
export function nextRestart(
|
|
27
|
+
stops: readonly number[],
|
|
28
|
+
now: number,
|
|
29
|
+
policy: RestartPolicy = RESTART_POLICY,
|
|
30
|
+
): RestartDecision {
|
|
31
|
+
const recent = stops.filter((at) => now - at < policy.windowMs).length
|
|
32
|
+
if (recent >= policy.maxInWindow) return { restart: false }
|
|
33
|
+
return {
|
|
34
|
+
restart: true,
|
|
35
|
+
delayMs: policy.delaysMs[Math.min(recent, policy.delaysMs.length - 1)] ?? 0,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const KEEP_DAYS = 14
|
|
40
|
+
|
|
41
|
+
/** The host's output, one file a day (`host-YYYY-MM-DD.log`) in the app's logs folder; files older than two weeks go. Never throws: a log must not stop the app. */
|
|
42
|
+
export function appendHostLog(dir: string, text: string, now: Date = new Date()): void {
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(dir, { recursive: true })
|
|
45
|
+
appendFileSync(join(dir, `host-${now.toISOString().slice(0, 10)}.log`), text)
|
|
46
|
+
for (const name of readdirSync(dir)) {
|
|
47
|
+
if (!/^host-\d{4}-\d{2}-\d{2}\.log$/u.test(name)) continue
|
|
48
|
+
const file = join(dir, name)
|
|
49
|
+
if (now.getTime() - statSync(file).mtimeMs > KEEP_DAYS * 86_400_000)
|
|
50
|
+
rmSync(file, { force: true })
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// The disk is full or the folder is gone: the host goes on.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `node --watch` does not end when the host it runs crashes: it prints this line and waits for a saved file (reproduced on a packed app by killing the host's
|
|
59
|
+
* child: the watcher stayed, the port was closed, nothing answered and nothing restarted). So the window reads the line as the host's death.
|
|
60
|
+
*/
|
|
61
|
+
export function hostCrashed(output: string): boolean {
|
|
62
|
+
return /Failed running '/u.test(output)
|
|
63
|
+
}
|
|
@@ -8,6 +8,8 @@ import { readFileSync } from 'node:fs'
|
|
|
8
8
|
import { createRequire } from 'node:module'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import { acrylRoot, appIdOf, personHome } from '../instance/home.ts'
|
|
12
|
+
import { appTitle, brandedElectron, iconFile } from './brand.ts'
|
|
11
13
|
|
|
12
14
|
/** The sysctl that makes the sandbox fail for an Electron without an AppArmor profile; its value, or `undefined` where it does not exist (every system but a recent Ubuntu). */
|
|
13
15
|
export const RESTRICT_USERNS = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns'
|
|
@@ -76,8 +78,22 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
|
76
78
|
const blocked =
|
|
77
79
|
process.env.ACRYLBLENDS_DESKTOP_SANDBOX !== '1' && sandboxBlocked(process.platform)
|
|
78
80
|
if (blocked) console.warn(SANDBOX_NOTICE)
|
|
81
|
+
// macOS: the Dock and Cmd+Tab name the app and draw its icon only when the bundle says so (brand.ts); a copy that cannot be made leaves the stock Electron, with a note.
|
|
82
|
+
const projectRoot = fileURLToPath(new URL('../..', import.meta.url))
|
|
83
|
+
const branded = brandedElectron({
|
|
84
|
+
binary: electron,
|
|
85
|
+
folder: join(
|
|
86
|
+
acrylRoot(process.platform, process.env, personHome(process.env, process.platform)),
|
|
87
|
+
'electron',
|
|
88
|
+
),
|
|
89
|
+
id: appIdOf(projectRoot),
|
|
90
|
+
name: appTitle(projectRoot),
|
|
91
|
+
icon: iconFile(projectRoot),
|
|
92
|
+
})
|
|
93
|
+
if (branded.problem !== undefined)
|
|
94
|
+
console.warn(`desktop: the Dock shows Electron's own name and icon (${branded.problem})`)
|
|
79
95
|
runElectron(
|
|
80
|
-
|
|
96
|
+
branded.binary,
|
|
81
97
|
electronArgs(join(import.meta.dirname, 'main.ts'), blocked, process.argv.slice(2)),
|
|
82
98
|
)
|
|
83
99
|
}
|