diff --git a/changelog.d/2643.fixed.md b/changelog.d/2643.fixed.md new file mode 100644 index 000000000..99ab43c27 --- /dev/null +++ b/changelog.d/2643.fixed.md @@ -0,0 +1 @@ +The `social`, `timeline` and `workflow` plugins now resolve `$DSH_HOME` the way the core does — a blank or whitespace value counts as unset, `~` expands, and the root is always absolute — so `bot/flybot-social.json`, `bot/flybot-timeline` and `bot/flybot-workflow` no longer move with the process's working directory ([#2643](https://github.com/ThinkFlowLab/FlyBot/issues/2643)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index dd91cc1d3..3da2eb2bc 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -10,6 +10,8 @@ The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) s Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and approved in the open chat (`flybot.session.dock`). The social sidebar strip shows a badge and a one-line jump list. The same queue is also attached to a native shared room via `flybot.roomApprovals.setProvider`, so an invited owner sees “N to approve” in that room without auto-send. +`$DSH_HOME` is resolved the way the core resolves it — a blank value counts as unset, `~` expands, and the root is absolute — so `bot/flybot-social.json` does not move with the process's working directory (#2643). + Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, after New group chat; `label` / `hint` use the same `t()` map). **Join by number or link** (zh 用号码或链接加入) and **Start a face-to-face room** (zh 面对面建群, #1021) are two rows, because one label naming a create path and a join path at once read as neither (#892 had one); **Add friend** is the third (copying an invite and your own handle live inside Add friend); the social strip has no **+ Add** button. Both face rows open the same screen with the same handlers, titled after the row that was tapped; the start path keeps the six-digit number (a room is built by typing one together) and drops the link and scan fields, which are ways into a group that already exists. The join path's copy names both ways in and its link field reads *Or paste a group invite link* (pick one or more local Bots, six-digit number, live waiting room that shows the code, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** for face rooms is one compact checkbox (tooltip) on the open room's overview, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, **main sidebar row**, and core group overview uses the host `BotAvatar` (published look, optional member `avatar`, else a handle-seeded look). **Sharing → Copy invite link** creates a per-Bot grant when the friend accepts; **Share with a friend** (#500) lists current friends who do not have this Bot yet and grants it in one click (Bot Social `grant_bot_to_friend`, companion Birfy/bot-social#34) without an invite link — one row per **Bot**, not per person, labelled `Bot (Owner)` and carrying that Bot's handle and `peer_bot_id`, so a friend who runs several Bots gets a row each and the click grants to the row's own Bot while the receipt names it (#973) — from the row label the click carries, not from the home key, so a server answer without names never prints `selftestb2` (#994), and a caller that sends no label gets `with your friend` instead; the filter asks per **Bot** too, so a friend whose Bot already has it still gets a row for the others and only a grant that names no Bot falls back to hiding that whole friend (#981); the friend is named by `friend_bot_id`, the peer id `list_friends` returns — the platform never lists a friend's owner id — and `friend_owner_id` still works for a server that only knows it (#592); a server without that tool tries the REST route `POST /v1/bots/:id/grants` and, when it 404s too, says *Bot Social needs an update to share with a friend. Invite links still work.* instead of the raw MCP error (the production `MCP error -32602: Tool … not found` wording counts as an unknown tool, #563); a server that has the tool but refuses the arguments says *Bot Social did not accept that share request. Its server may need an update; invite links still work.*, never the raw `-32602` text (#592); A failed **Share** and a failed friend-list read keep separate notes (#987) — two slots, not one (#992): the share outcome (`share-note`) and the list error (`share-list-error`) are drawn side by side, so a share click cannot erase the list error and its **Try again**, and the 5 s poll cannot erase the share's message; a successful read settles the list error only, a successful share settles the share line only, and "No friends left to share with" is suppressed by a list error only — a share error never speaks for the list. **People with access** lists one row per **Bot** that has this Bot — named `Bot (Owner)` like the share list, carrying that Bot's handle, the groups it is used in, and its own **Revoke** (#995). **Revoke** asks once before it runs (#1073): the button opens `Revoke ? They lose this Bot.` (and `in N rooms` when that row already names rooms) with **Revoke** and **Keep**, and never the browser confirm box. The handle, or the home when the row names no Bot, and the usage hint share one line with a middle dot between them (`@handle · In use in N rooms`), so the two never run together (#1073). One **Auto-send** switch per person. The hint stays one readable row however long the share lives (#955): it names at most three rooms and then `+N more`, keeps every room one per line in its tooltip, and a room a grant row already named is not printed again under **This room only**. There is no published toggle. A line that Bot sends 1:1 to a seat in that room is judged under the room (#948): the plugin resolves it before the member check (`roomFor` → `roomContextFor`), the seat itself is the target when the roster does not list it (`pairFor`), and the send names that room (`room_id`) so Bot Social checks the same consent the host already did; the `roomOnly` identity is neither dropped nor widened — with no such room the refusal is the same sentence as before, and `send_as_member` is never reached. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). ## Owns diff --git a/docs/subsystems/timeline.md b/docs/subsystems/timeline.md index 28fa93b4c..1f4d9909a 100644 --- a/docs/subsystems/timeline.md +++ b/docs/subsystems/timeline.md @@ -8,6 +8,8 @@ owned_paths: Where time goes across the local Bot team (#241). The plugin records Bot turns, tool calls and Bot-to-Bot deliveries from the core's activity feed, keeps a capped trace on disk, and answers the panel's poll on `flybot/timeline/window`. It lives entirely under `plugins/timeline/`. +`$DSH_HOME` is resolved the way the core resolves it — a blank value counts as unset, `~` expands, and the root is absolute — so `bot/flybot-timeline` does not move with the process's working directory (#2643). + ## Owns Everything in `plugins/timeline/`: the recorder, the trace file, the read model behind the endpoint and the browser entry (`client.js`: data layer and the panel). diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index c8e882c26..47e12a33f 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -8,6 +8,8 @@ owned_paths: A group-shaped chat of local Bots, with a flowchart the owner can drag once **Edit** is on (#1318, #1378). The plugin stores the graphs under `$DSH_HOME/bot/flybot-workflow` and answers `flybot/workflow/*`. It lives entirely under `plugins/workflow/`. +`$DSH_HOME` is resolved the way the core resolves it — a blank value counts as unset, `~` expands, and the root is absolute — so `bot/flybot-workflow` does not move with the process's working directory (#2643). + This is not the workflow-definition engine. That engine (#1272) stores `workflows/.json` and guards artifacts. This plugin does not read or write those files and does not call that engine. Settings → Workflows (#1364) is this plugin's list. The page lives in the core client and calls `workflow/state`, `workflow/create` and `workflow/graph`. Factory rows are `templateCatalog()` — id, name, description, triggers, plus the drawable `nodes`, `edges`, `layers` and `chain` — and the owner's rows are the saved workflows. The settings page does not replace the sidebar chat. A sidebar click opens this plugin's stage and leaves the Workflows section in place. diff --git a/plugins/social/index.js b/plugins/social/index.js index 2b425b19e..10f449bd9 100644 --- a/plugins/social/index.js +++ b/plugins/social/index.js @@ -15,6 +15,7 @@ // presence false turns off "Name is thinking…" for friends' Bots (Bot Social presence signal, #297; default on) // token / botId legacy per-bot identity (no home): owner connector token + bot id import { join } from 'node:path' +import { dshHomeOf } from './src/dsh-home.js' import { NS, registerUnavailable, startFailureReason, startSocial } from './src/social.js' import { createServerController } from './src/server-config.js' @@ -26,7 +27,7 @@ export function apply(ctx, config = {}) { // Owner switch (#2564): off means this process registers none of the plugin's tools, // slots or endpoints. Read from FlyBot's state.json at start, so it applies next launch. if (ctx.flybot?.pluginEnabled?.('social') === false) return - const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? '.', '.dsh') + const dshHome = dshHomeOf() const base = { inboxPollMs: 15_000, facePollMs: 4_000, credentialsPath: join(dshHome, 'bot', 'flybot-social.json'), ...config } const flybot = typeof ctx.flybot.scope === 'function' ? ctx.flybot.scope(NS) : ctx.flybot const warn = (...args) => console.warn('flybot-social:', ...args) diff --git a/plugins/social/src/dsh-home.js b/plugins/social/src/dsh-home.js new file mode 100644 index 000000000..057c3cfe0 --- /dev/null +++ b/plugins/social/src/dsh-home.js @@ -0,0 +1,9 @@ +// The FlyBot data root, resolved the way the core does (core/src/host/folders.js#dshHomeOf): +// a blank DSH_HOME counts as unset, `~` expands, and the answer is absolute. A peer plugin +// cannot import core/, so the rule is kept here rather than depending on a core path. +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +const expandHome = text => (text === '~' ? homedir() : /^~[\\/]/.test(text) ? join(homedir(), text.slice(2)) : text) + +export const dshHomeOf = (value = process.env.DSH_HOME) => resolve(expandHome(value?.trim() ? value : join(homedir(), '.dsh'))) diff --git a/plugins/social/test/dsh-home.test.js b/plugins/social/test/dsh-home.test.js new file mode 100644 index 000000000..1fc6c9bf7 --- /dev/null +++ b/plugins/social/test/dsh-home.test.js @@ -0,0 +1,95 @@ +// The credentials root is the DSH home, resolved the way the core does (#2643): a blank +// DSH_HOME counts as unset and `~` expands, and the file must not move with the process's +// working directory (a wrong root is a wrong-file write, not a display matter). The default +// subdirectory (`bot/flybot-social.json`) is unchanged. +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { isAbsolute, join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' +import { dshHomeOf } from '../src/dsh-home.js' + +const fakeFlybot = (handlers) => { + const fb = { + api: { handle: (name, fn) => { handlers.set(name, fn); return () => {} } }, + fields: { get: () => undefined, set: () => {} }, + grants: { of: () => [], set: async () => {}, has: () => false }, + bot: { list: () => [] }, + rooms: { list: () => [] }, + readOnly: () => true, + warn: () => {}, + on: () => () => {}, + } + return { ...fb, pluginEnabled: () => true, scope: () => fb } +} + +const credentialsFile = home => join(home, '.dsh', 'bot', 'flybot-social.json') + +function credentialsAt(home, server) { + mkdirSync(join(home, '.dsh', 'bot'), { recursive: true }) + writeFileSync(credentialsFile(home), JSON.stringify({ server, token: 'test-token' }), { mode: 0o600 }) +} + +async function statusAt(cwd) { + process.chdir(cwd) + const handlers = new Map() + const ctx = { flybot: fakeFlybot(handlers), effect(fn) { this.off = fn() } } + apply(ctx) + try { + return await handlers.get('social/server')() + } finally { + ctx.off?.() + } +} + +async function withEnv(env, run) { + const saved = { DSH_HOME: process.env.DSH_HOME, HOME: process.env.HOME, cwd: process.cwd() } + Object.assign(process.env, env) + try { + return await run() + } finally { + for (const key of ['DSH_HOME', 'HOME']) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + process.chdir(saved.cwd) + } +} + +test('the credentials file follows the DSH home whatever the working directory', async () => { + const home = mkdtempSync(join(tmpdir(), 'flybot-social-home-')) + const a = mkdtempSync(join(tmpdir(), 'flybot-social-cwd-a-')) + const b = mkdtempSync(join(tmpdir(), 'flybot-social-cwd-b-')) + try { + credentialsAt(home, 'https://peer.test') + await withEnv({ DSH_HOME: '', HOME: home }, async () => { + const first = await statusAt(a) + const second = await statusAt(b) + assert.equal(isAbsolute(dshHomeOf()), true, 'the resolved root is absolute') + assert.deepEqual(first.known, ['https://peer.test'], 'the saved server is read from the DSH home') + assert.deepEqual(second.known, first.known, 'two working directories resolve to one credentials root') + }) + } finally { + rmSync(home, { recursive: true, force: true }) + rmSync(a, { recursive: true, force: true }) + rmSync(b, { recursive: true, force: true }) + } +}) + +test('a blank DSH_HOME counts as unset and ~ expands like the core', async () => { + const home = mkdtempSync(join(tmpdir(), 'flybot-social-home-')) + try { + await withEnv({ DSH_HOME: ' ', HOME: home }, () => { + assert.equal(dshHomeOf(), join(home, '.dsh')) + }) + await withEnv({ DSH_HOME: '~/.dsh', HOME: home }, () => { + assert.equal(dshHomeOf(), join(home, '.dsh')) + }) + await withEnv({ DSH_HOME: '~/data', HOME: home }, () => { + assert.equal(dshHomeOf(), join(home, 'data')) + }) + } finally { + rmSync(home, { recursive: true, force: true }) + } +}) diff --git a/plugins/timeline/index.js b/plugins/timeline/index.js index b8818ed12..def411452 100644 --- a/plugins/timeline/index.js +++ b/plugins/timeline/index.js @@ -25,6 +25,7 @@ import { appendFile, stat } from 'node:fs/promises' import { join } from 'node:path' import { createAdapter } from './src/adapter.js' +import { dshHomeOf } from './src/dsh-home.js' import { createTimeline } from './src/timeline.js' export const name = 'flybot-timeline' @@ -95,7 +96,7 @@ export function apply(ctx, config = {}) { if (ctx.flybot?.pluginEnabled?.('timeline') === false) return { timeline: undefined, enabled: false, dispose() {} } if (config?.enabled !== true) return { timeline: undefined, enabled: false, dispose() {} } const adapter = createAdapter(ctx.flybot) - const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? '.', '.dsh') + const dshHome = dshHomeOf() const { dir = join(dshHome, 'bot', 'flybot-timeline'), persist, segmentRecords, segments, retentionMs, flushMs } = config const limits = Object.fromEntries(Object.entries({ segmentRecords, segments, retentionMs, flushMs }).filter(([, value]) => Number.isFinite(value) && value > 0)) const timeline = createTimeline(adapter, { dir, persist: persist !== false, exporter: makeExporter(config, dir, adapter.warn), ...limits }) diff --git a/plugins/timeline/src/dsh-home.js b/plugins/timeline/src/dsh-home.js new file mode 100644 index 000000000..057c3cfe0 --- /dev/null +++ b/plugins/timeline/src/dsh-home.js @@ -0,0 +1,9 @@ +// The FlyBot data root, resolved the way the core does (core/src/host/folders.js#dshHomeOf): +// a blank DSH_HOME counts as unset, `~` expands, and the answer is absolute. A peer plugin +// cannot import core/, so the rule is kept here rather than depending on a core path. +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +const expandHome = text => (text === '~' ? homedir() : /^~[\\/]/.test(text) ? join(homedir(), text.slice(2)) : text) + +export const dshHomeOf = (value = process.env.DSH_HOME) => resolve(expandHome(value?.trim() ? value : join(homedir(), '.dsh'))) diff --git a/plugins/timeline/test/dsh-home.test.js b/plugins/timeline/test/dsh-home.test.js new file mode 100644 index 000000000..082ea1ce3 --- /dev/null +++ b/plugins/timeline/test/dsh-home.test.js @@ -0,0 +1,81 @@ +// The trace root is the DSH home, resolved the way the core does (#2643): a blank DSH_HOME +// counts as unset and `~` expands, and the answer must not move with the process's working +// directory. The default subdirectory (`bot/flybot-timeline`) is unchanged. +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { isAbsolute, join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' + +const flybot = () => ({ + scope: () => ({ + on: () => () => {}, + bot: { list: () => [] }, + rooms: { list: () => [] }, + readOnly: () => true, + warn: () => {}, + api: { handle: () => () => {} }, + }), +}) + +function traceDirAt(cwd) { + process.chdir(cwd) + const ctx = { flybot: flybot(), effect(fn) { this.off = fn() } } + const result = apply(ctx, { enabled: true, persist: false }) + const dir = result.timeline.trace.dir + result.dispose() + return dir +} + +function withEnv(env, run) { + const saved = { DSH_HOME: process.env.DSH_HOME, HOME: process.env.HOME, cwd: process.cwd() } + Object.assign(process.env, env) + try { + return run() + } finally { + for (const key of ['DSH_HOME', 'HOME']) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + process.chdir(saved.cwd) + } +} + +test('the trace root is absolute and does not follow the working directory', () => { + const home = mkdtempSync(join(tmpdir(), 'flybot-timeline-home-')) + const a = mkdtempSync(join(tmpdir(), 'flybot-timeline-cwd-a-')) + const b = mkdtempSync(join(tmpdir(), 'flybot-timeline-cwd-b-')) + try { + withEnv({ DSH_HOME: '', HOME: home }, () => { + const first = traceDirAt(a) + const second = traceDirAt(b) + assert.equal(isAbsolute(first), true, 'the trace root is absolute') + assert.equal(first, join(home, '.dsh', 'bot', 'flybot-timeline')) + assert.equal(second, first, 'two working directories resolve to one trace root') + }) + } finally { + rmSync(home, { recursive: true, force: true }) + rmSync(a, { recursive: true, force: true }) + rmSync(b, { recursive: true, force: true }) + } +}) + +test('a blank DSH_HOME counts as unset and ~ expands like the core', () => { + const home = mkdtempSync(join(tmpdir(), 'flybot-timeline-home-')) + const cwd = mkdtempSync(join(tmpdir(), 'flybot-timeline-cwd-')) + try { + withEnv({ DSH_HOME: ' ', HOME: home }, () => { + assert.equal(traceDirAt(cwd), join(home, '.dsh', 'bot', 'flybot-timeline')) + }) + withEnv({ DSH_HOME: '~/.dsh', HOME: home }, () => { + assert.equal(traceDirAt(cwd), join(home, '.dsh', 'bot', 'flybot-timeline')) + }) + withEnv({ DSH_HOME: '/var/lib/flybot', HOME: home }, () => { + assert.equal(traceDirAt(cwd), '/var/lib/flybot/bot/flybot-timeline') + }) + } finally { + rmSync(home, { recursive: true, force: true }) + rmSync(cwd, { recursive: true, force: true }) + } +}) diff --git a/plugins/workflow/index.js b/plugins/workflow/index.js index 78325029d..148887568 100644 --- a/plugins/workflow/index.js +++ b/plugins/workflow/index.js @@ -2,6 +2,7 @@ // $DSH_HOME/bot/flybot-workflow and answers the chat surface on workflow/*. import { randomBytes } from 'node:crypto' import { join } from 'node:path' +import { dshHomeOf } from './src/dsh-home.js' import { permissionFor } from './src/ephemeral.js' import { createWorkflowService } from './src/service.js' @@ -27,7 +28,7 @@ export function apply(ctx, config = {}) { throw new Error('@flybot/workflow needs ctx.flybot.scope (flybot API v1)') } const fb = ctx.flybot.scope('flybot-workflow') - const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? '.', '.dsh') + const dshHome = dshHomeOf() const persist = config.persist !== false // The runner names held by the ephemeral Bots this plugin spawned, keyed by // Bot id, so a release gives its name back instead of keeping it for the diff --git a/plugins/workflow/src/dsh-home.js b/plugins/workflow/src/dsh-home.js new file mode 100644 index 000000000..057c3cfe0 --- /dev/null +++ b/plugins/workflow/src/dsh-home.js @@ -0,0 +1,9 @@ +// The FlyBot data root, resolved the way the core does (core/src/host/folders.js#dshHomeOf): +// a blank DSH_HOME counts as unset, `~` expands, and the answer is absolute. A peer plugin +// cannot import core/, so the rule is kept here rather than depending on a core path. +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' + +const expandHome = text => (text === '~' ? homedir() : /^~[\\/]/.test(text) ? join(homedir(), text.slice(2)) : text) + +export const dshHomeOf = (value = process.env.DSH_HOME) => resolve(expandHome(value?.trim() ? value : join(homedir(), '.dsh'))) diff --git a/plugins/workflow/test/dsh-home.test.js b/plugins/workflow/test/dsh-home.test.js new file mode 100644 index 000000000..aad78d688 --- /dev/null +++ b/plugins/workflow/test/dsh-home.test.js @@ -0,0 +1,95 @@ +// The workflow store root is the DSH home, resolved the way the core does (#2643): a blank +// DSH_HOME counts as unset and `~` expands, and the store must not move with the process's +// working directory. The default subdirectory (`bot/flybot-workflow`) is unchanged. +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { isAbsolute, join } from 'node:path' +import { test } from 'node:test' +import { apply } from '../index.js' + +const fakeFlybot = (handlers) => { + const fb = { + api: { handle: (name, fn) => { handlers.set(name, fn); return () => {} } }, + tools: { register: () => () => {} }, + bot: { list: () => [] }, + rooms: { list: () => [] }, + readOnly: () => false, + warn: () => {}, + } + return { ...fb, pluginEnabled: () => true, scope: () => fb } +} + +async function startAt(cwd, handlers) { + process.chdir(cwd) + const ctx = { flybot: fakeFlybot(handlers), effect(fn) { this.off = fn() } } + const plugin = apply(ctx, {}) + return { plugin, stop: async () => { await plugin.dispose() } } +} + +async function withEnv(env, run) { + const saved = { DSH_HOME: process.env.DSH_HOME, HOME: process.env.HOME, cwd: process.cwd() } + Object.assign(process.env, env) + try { + return await run() + } finally { + for (const key of ['DSH_HOME', 'HOME']) { + if (saved[key] === undefined) delete process.env[key] + else process.env[key] = saved[key] + } + process.chdir(saved.cwd) + } +} + +const storeFile = (home, name) => join(home, '.dsh', 'bot', 'flybot-workflow', name) + +test('the workflow store lands in the DSH home whatever the working directory', async () => { + const home = mkdtempSync(join(tmpdir(), 'flybot-workflow-home-')) + const a = mkdtempSync(join(tmpdir(), 'flybot-workflow-cwd-a-')) + const b = mkdtempSync(join(tmpdir(), 'flybot-workflow-cwd-b-')) + try { + await withEnv({ DSH_HOME: '', HOME: home }, async () => { + const first = await startAt(a, new Map()) + try { + const created = first.plugin.service.create({ name: 'Root', nodes: [{ id: 'n1', name: 'Step', prompt: 'Do the thing' }], layers: [['n1']] }) + assert.equal(created.ok, true, `create answered ${JSON.stringify(created)}`) + assert.equal(isAbsolute(storeFile(home, 'workflows.json')), true) + assert.equal(existsSync(storeFile(home, 'workflows.json')), true, 'the store is under the DSH home') + assert.equal(existsSync(join(a, 'bot', 'flybot-workflow', 'workflows.json')), false, 'nothing is written beside the cwd') + } finally { + await first.stop() + } + + const second = await startAt(b, new Map()) + try { + const names = second.plugin.service.state().workflows.map(item => item.name) + assert.equal(names.includes('Root'), true, `another working directory reads the same store (${names.join(', ')})`) + } finally { + await second.stop() + } + }) + } finally { + rmSync(home, { recursive: true, force: true }) + rmSync(a, { recursive: true, force: true }) + rmSync(b, { recursive: true, force: true }) + } +}) + +test('a blank DSH_HOME counts as unset and ~ expands like the core', async () => { + const home = mkdtempSync(join(tmpdir(), 'flybot-workflow-home-')) + const cwd = mkdtempSync(join(tmpdir(), 'flybot-workflow-cwd-')) + try { + await withEnv({ DSH_HOME: ' ', HOME: home }, async () => { + const run = await startAt(cwd, new Map()) + try { + assert.equal(run.plugin.service.create({ name: 'Blank', nodes: [{ id: 'n1', name: 'Step', prompt: 'Do the thing' }], layers: [['n1']] }).ok, true) + assert.equal(existsSync(storeFile(home, 'workflows.json')), true) + } finally { + await run.stop() + } + }) + } finally { + rmSync(home, { recursive: true, force: true }) + rmSync(cwd, { recursive: true, force: true }) + } +})