393 lines
25 KiB
Diff
393 lines
25 KiB
Diff
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
|
|
index 20be532e9..fc38cefab 100644
|
|
--- a/docs/TOOLS.md
|
|
+++ b/docs/TOOLS.md
|
|
@@ -469,7 +469,19 @@ user entry may carry `channels`, an allowlist of listed channel ids.
|
|
`scripts/discord-service.sh install` renders and writes the systemd user
|
|
unit `[email protected]` (one instance per binding, restart on
|
|
failure, exit 3 never retried, SIGTERM on `systemctl --user stop`, SIGHUP on
|
|
-`systemctl --user reload`). An optional `tools` key in the binding declares
|
|
+`systemctl --user reload`). With
|
|
+`install --copy ID [--copies DIR]`, a full id selects an existing shared
|
|
+copy holding the Discord CLI and pi; ExecStart runs Node on that CLI with
|
|
+--repo and WorkingDirectory in the copy. Without --copy the checkout unit
|
|
+is unchanged. Status names its effective cwd/copy id, including drop-ins.
|
|
+`check --offline` validates local binding/context/tool roots/pi/token mode
|
|
+and reports journal/STOP without REST or gateway calls; run refuses this
|
|
+flag. For a Mos-approved re-pin after the SetSpark freeze: make the copy,
|
|
+run `node <copy>/packages/discord/src/cli.mjs check <binding> --repo <copy> --offline`,
|
|
+save the installed unit, install --copy, then restart on Mos's go. Context
|
|
+files resolve inside the copy and missing files refuse. Rollback restores
|
|
+the saved unit and daemon-reloads/restarts on Mos's go; keep both copies.
|
|
+The connector's pin is independent of stack-copy.sh check. An optional `tools` key in the binding declares
|
|
file roots; pi then runs with its own tools off and the package's
|
|
extension providing `list_dir`, `read_file` and `search` confined to those
|
|
roots, plus `write_file` and `edit_file` for roots marked `"write": true`
|
|
diff --git a/packages/discord/README.md b/packages/discord/README.md
|
|
index 8f235b556..29b9fc7cc 100644
|
|
--- a/packages/discord/README.md
|
|
+++ b/packages/discord/README.md
|
|
@@ -17,7 +17,7 @@ CHAT-03 conversation controller exists.
|
|
## Commands
|
|
|
|
```
|
|
-scripts/discord.sh check <binding>
|
|
+scripts/discord.sh check <binding> [--offline]
|
|
scripts/discord.sh run <binding> [--supervised]
|
|
scripts/discord.sh stop <binding>
|
|
scripts/discord.sh unlock <binding>
|
|
@@ -34,6 +34,9 @@ scripts/discord-service.sh render | install | uninstall | status <binding>
|
|
every listed channel over REST; opens one gateway connection, waits for
|
|
READY, closes it. Nothing is sent to a channel. Close code 4014 is reported
|
|
as the message-content intent not being granted in the developer portal.
|
|
+ `check --offline` stops after local binding/context/tool roots/pi/token-mode
|
|
+ validation and journal/STOP reporting. It makes no REST or gateway call
|
|
+ and says so in its last line. `run --offline` is a usage error.
|
|
- `run` refuses when `STOP` exists or an unresolved delivery cannot be
|
|
reconciled. Otherwise it starts pi, connects, and serves turns until
|
|
SIGTERM, SIGINT or `stop`. Run it under the service unit below, or by hand
|
|
@@ -121,6 +124,25 @@ at runtime, as before. Surviving logout and reboot needs
|
|
`uninstall` refuses while an instance is active. The log goes to journald:
|
|
`run` writes no message text to stderr, only ids, counts and state.
|
|
|
|
+Pinned install: `scripts/discord-service.sh install --copy ID [--copies DIR]`
|
|
+uses a full 40-character copy id under the shared mosaic-stack/copies
|
|
+location. It requires the copy's Discord CLI and node_modules/.bin/pi,
|
|
+runs Node directly on that CLI with --repo and WorkingDirectory set to
|
|
+the copy, and retains the unit's restart, brake and stop behavior. Without
|
|
+--copy the unit still runs the checkout; install explicitly recommends
|
|
+pinning. Status reads effective WorkingDirectory, including drop-ins, and
|
|
+prints the tree and copy id or checkout path.
|
|
+
|
|
+Every live step requires Mos's go after the SetSpark freeze lifts. Re-pin:
|
|
+make the new pushed copy with stack-copy.sh; run
|
|
+`node <copy>/packages/discord/src/cli.mjs check <binding> --repo <copy> --offline`;
|
|
+preserve the old installed unit file; install --copy ID; reload/restart on
|
|
+Mos's go. The copy has no scripts/discord.sh, so use its CLI directly.
|
|
+Binding context.files resolve against the copy: a file not copied refuses
|
|
+at start/check. Rollback restores the saved unit file, daemon-reloads and
|
|
+restarts only on Mos's go; retain both copies. This connector pin is
|
|
+independent of the bus/Console/CLI pin and stack-copy.sh check.
|
|
+
|
|
## The binding
|
|
|
|
Deployment policy for one seat on one server. It carries Discord ids of real
|
|
diff --git a/packages/discord/src/cli.mjs b/packages/discord/src/cli.mjs
|
|
index 045f4918e..ca8c8bdde 100755
|
|
--- a/packages/discord/src/cli.mjs
|
|
+++ b/packages/discord/src/cli.mjs
|
|
@@ -1,6 +1,6 @@
|
|
#!/usr/bin/env node
|
|
// Usage:
|
|
-// mosaic-discord check <binding> [--config PATH] [--repo PATH]
|
|
+// mosaic-discord check <binding> [--config PATH] [--repo PATH] [--offline]
|
|
// mosaic-discord run <binding> [--config PATH] [--repo PATH] [--supervised]
|
|
// mosaic-discord stop <binding> [--config PATH]
|
|
// mosaic-discord unlock <binding> [--config PATH]
|
|
@@ -61,7 +61,7 @@ import { createConnector } from "./connector.mjs";
|
|
import { ensureJournal, requestStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, BRAKE_EXIT } from "./journal.mjs";
|
|
|
|
const USAGE = [
|
|
- "usage: mosaic-discord check <binding> [--config PATH] [--repo PATH]",
|
|
+ "usage: mosaic-discord check <binding> [--config PATH] [--repo PATH] [--offline]",
|
|
" mosaic-discord run <binding> [--config PATH] [--repo PATH] [--supervised]",
|
|
" mosaic-discord stop <binding> [--config PATH]",
|
|
" mosaic-discord unlock <binding> [--config PATH]",
|
|
@@ -70,7 +70,7 @@ const USAGE = [
|
|
].join("\n");
|
|
|
|
function parse(argv) {
|
|
- const opts = { command: null, binding: null, config: defaultConfigPath(), repo: process.cwd(), supervised: false };
|
|
+ const opts = { command: null, binding: null, config: defaultConfigPath(), repo: process.cwd(), supervised: false, offline: false };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === "--config" || a === "--repo") {
|
|
@@ -80,6 +80,8 @@ function parse(argv) {
|
|
opts.command = "help";
|
|
} else if (a === "--supervised") {
|
|
opts.supervised = true;
|
|
+ } else if (a === "--offline") {
|
|
+ opts.offline = true;
|
|
} else if (a.startsWith("--")) throw new DiscordError(`unknown argument: ${a}\n${USAGE}`, 4);
|
|
else if (opts.command === null) opts.command = a;
|
|
else if (opts.binding === null) opts.binding = a;
|
|
@@ -89,6 +91,7 @@ function parse(argv) {
|
|
if (!["check", "run", "stop", "unlock", "recover", "reload"].includes(opts.command)) throw new DiscordError(USAGE, 4);
|
|
if (opts.binding === null) throw new DiscordError(`${opts.command} needs a binding name\n${USAGE}`, 4);
|
|
if (opts.supervised && opts.command !== "run") throw new DiscordError(`--supervised applies to run only\n${USAGE}`, 4);
|
|
+ if (opts.offline && opts.command !== "check") throw new DiscordError(`--offline applies to check only\n${USAGE}`, 4);
|
|
return opts;
|
|
}
|
|
|
|
@@ -123,6 +126,11 @@ async function check(opts) {
|
|
say(toolRoots ? `tools: ${enabledToolNames(toolRoots).join(",")}; roots ${toolRoots.roots.map((r) => `${r.name}=${r.path}${r.write ? " (writable)" : ""}`).join(" ")}, ${toolRoots.maxCallsPerTurn} calls/message, ${toolRoots.maxFileBytes} bytes/file${toolRoots.web ? `, web via ${toolRoots.web.searxng} (${toolRoots.web.maxFetchBytes} bytes/page)` : ""}${toolRoots.setspark ? `, setspark via ${toolRoots.setspark.baseUrl} as ${toolRoots.setspark.principal}` : ""}` : "tools: none");
|
|
say(`token file mode 0600 ok; STOP ${stopRequested(journalDir) ? "PRESENT" : "absent"}`);
|
|
|
|
+ if (opts.offline) {
|
|
+ say("offline check passed; no REST or gateway calls were made");
|
|
+ return;
|
|
+ }
|
|
+
|
|
const rest = createRest({ token, log: warn });
|
|
const me = await rest.getMe();
|
|
if (me.id !== binding.botUserId) throw new DiscordError(`token belongs to bot ${me.id}, binding says ${binding.botUserId}`);
|
|
diff --git a/packages/discord/tests/copy-service.test.mjs b/packages/discord/tests/copy-service.test.mjs
|
|
new file mode 100644
|
|
index 000000000..c2445701f
|
|
--- /dev/null
|
|
+++ b/packages/discord/tests/copy-service.test.mjs
|
|
@@ -0,0 +1,26 @@
|
|
+import test from 'node:test';
|
|
+import assert from 'node:assert/strict';
|
|
+import {spawnSync} from 'node:child_process';
|
|
+import {readFileSync,writeFileSync,mkdirSync,unlinkSync,chmodSync,existsSync} from 'node:fs';
|
|
+import {join,dirname} from 'node:path';
|
|
+import {fileURLToPath} from 'node:url';
|
|
+import {makeRoot} from './helpers.mjs';
|
|
+const ROOT=fileURLToPath(new URL('../../../',import.meta.url)).replace(/\/$/,'');
|
|
+const SCRIPT=join(ROOT,'scripts/discord-service.sh');
|
|
+function setup(){const root=makeRoot(),copies=join(root,'copies'),id='a'.repeat(40),copy=join(copies,id),units=join(root,'units');mkdirSync(join(copy,'packages/discord/src'),{recursive:true});mkdirSync(join(copy,'node_modules/.bin'),{recursive:true});writeFileSync(join(copy,'packages/discord/src/cli.mjs'),'// fixture');writeFileSync(join(copy,'node_modules/.bin/pi'),'#!/bin/sh\nexit 0\n',{mode:0o755});const env={PATH:process.env.PATH,HOME:root,XDG_CONFIG_HOME:join(root,'config')};const run=args=>spawnSync(SCRIPT,args,{env,encoding:'utf8',timeout:5000});return {root,copies,id,copy,units,run,env};}
|
|
+test('pinned Discord render/install and missing prerequisites; checkout bytes unchanged',()=>{
|
|
+ const s=setup(),args=['--copy',s.id,'--copies',s.copies],r=s.run(['render',...args]);assert.equal(r.status,0,r.stderr);
|
|
+ assert.ok(r.stdout.includes(`WorkingDirectory=${s.copy}\n`));assert.ok(r.stdout.includes(`ExecStart=${process.execPath} ${s.copy}/packages/discord/src/cli.mjs run %i --supervised --repo ${s.copy}\n`));
|
|
+ for(const line of r.stdout.split('\n').filter(l=>/^(ExecStart|WorkingDirectory)=/.test(l)))assert.ok(!line.includes(ROOT),line);
|
|
+ const legacy=s.run(['render']);assert.equal(legacy.status,0,legacy.stderr);
|
|
+ const nodeDir=dirname(process.execPath),originalPath='/usr/local/bin:/usr/bin:/bin',path=originalPath.split(':').includes(nodeDir)?originalPath:nodeDir+':'+originalPath;
|
|
+ const expected=readFileSync(join(ROOT,'packages/discord/systemd/[email protected]'),'utf8').replaceAll('@REPO@',ROOT).replaceAll('@PATH@',path);assert.equal(legacy.stdout,expected);
|
|
+ assert.equal(r.stdout.split('\n').filter(l=>!/^(ExecStart|WorkingDirectory)=/.test(l)).join('\n'),legacy.stdout.split('\n').filter(l=>!/^(ExecStart|WorkingDirectory)=/.test(l)).join('\n'));
|
|
+ const installed=s.run(['install',...args,'--dir',s.units,'--no-reload']);assert.equal(installed.status,0,installed.stderr);assert.equal(readFileSync(join(s.units,'[email protected]'),'utf8'),r.stdout);
|
|
+ for(const id of ['','short','b'.repeat(40)])assert.equal(s.run(['install','--copy',id,'--copies',s.copies,'--dir',join(s.root,'missing'),'--no-reload']).status,1);
|
|
+ assert.ok(!existsSync(join(s.root,'missing/[email protected]')));
|
|
+ const cli=join(s.copy,'packages/discord/src/cli.mjs');unlinkSync(cli);assert.equal(s.run(['install',...args,'--dir',join(s.root,'missing-cli'),'--no-reload']).status,1);assert.ok(!existsSync(join(s.root,'missing-cli/[email protected]')));writeFileSync(cli,'// fixture');unlinkSync(join(s.copy,'node_modules/.bin/pi'));assert.equal(s.run(['install',...args,'--dir',join(s.root,'missing-pi'),'--no-reload']).status,1);assert.ok(!existsSync(join(s.root,'missing-pi/[email protected]')));
|
|
+});
|
|
+test('Discord status reports effective WorkingDirectory from a fake user manager',()=>{
|
|
+ const s=setup(),bin=join(s.root,'bin');mkdirSync(bin);const script=join(bin,'systemctl');writeFileSync(script,`#!/bin/sh\ncase "$*" in *show*)echo 'WorkingDirectory=${s.copy}';; *)echo inactive;;esac\n`);chmodSync(script,0o755);s.env.PATH=bin+':'+s.env.PATH;const r=s.run(['status','fixture']);assert.equal(r.status,0,r.stderr);assert.ok(r.stdout.includes(`copy: ${s.id}`));assert.ok(r.stdout.includes(`tree: ${s.copy}`));
|
|
+});
|
|
diff --git a/packages/discord/tests/offline-check.test.mjs b/packages/discord/tests/offline-check.test.mjs
|
|
new file mode 100644
|
|
index 000000000..ae069322b
|
|
--- /dev/null
|
|
+++ b/packages/discord/tests/offline-check.test.mjs
|
|
@@ -0,0 +1,26 @@
|
|
+import test from 'node:test';
|
|
+import assert from 'node:assert/strict';
|
|
+import {spawnSync} from 'node:child_process';
|
|
+import {writeFileSync,chmodSync,unlinkSync,existsSync} from 'node:fs';
|
|
+import {join} from 'node:path';
|
|
+import {fileURLToPath} from 'node:url';
|
|
+import {makeRoot,makeRepo,makeDeployment} from './helpers.mjs';
|
|
+const CLI=fileURLToPath(new URL('../src/cli.mjs',import.meta.url));
|
|
+function setup(){
|
|
+ const root=makeRoot(),repo=makeRepo(root),d=makeDeployment(root,{}, {tokenContent:'MTAw.abcdefghijklmnopqrstuvwxyz0123456789'}),calls=join(root,'network-called'),preload=join(root,'no-network.mjs');
|
|
+ writeFileSync(preload,`import {writeFileSync} from 'node:fs';const refuse=()=>{writeFileSync(${JSON.stringify(calls)},'called');throw Error('fixture refuses network')};globalThis.fetch=refuse;globalThis.WebSocket=class{constructor(){refuse()}};`);
|
|
+ const run=(command='check',args=[])=>spawnSync(process.execPath,['--import',preload,CLI,command,d.raw.name,'--config',d.config,'--repo',repo,...args],{encoding:'utf8',timeout:5000});
|
|
+ return {root,repo,d,calls,run};
|
|
+}
|
|
+test('check --offline validates local fixture without REST or gateway calls',()=>{
|
|
+ const s=setup(),r=s.run('check',['--offline']);assert.equal(r.status,0,r.stderr);assert.match(r.stdout,/token file mode 0600 ok; STOP absent/);assert.match(r.stdout,/offline check passed; no REST or gateway calls were made\n$/);assert.ok(!existsSync(s.calls));
|
|
+ // Online check still reaches REST: the trap fails it immediately.
|
|
+ const online=s.run();assert.equal(online.status,1);assert.ok(existsSync(s.calls));assert.match(online.stderr,/fixture refuses network/);
|
|
+});
|
|
+test('check --offline refuses token mode and missing context under the selected repo',()=>{
|
|
+ const s=setup();chmodSync(s.d.tokenFile,0o644);const bad=s.run('check',['--offline']);assert.equal(bad.status,2,bad.stderr);assert.match(bad.stderr,/0600/);assert.ok(!existsSync(s.calls));
|
|
+ chmodSync(s.d.tokenFile,0o600);unlinkSync(join(s.repo,'contracts/CONSTITUTION.md'));const context=s.run('check',['--offline']);assert.equal(context.status,2,context.stderr);assert.ok(!existsSync(s.calls));
|
|
+});
|
|
+test('run --offline is a usage refusal before reading config or networking',()=>{
|
|
+ const s=setup();unlinkSync(s.d.config);const r=s.run('run',['--offline']);assert.equal(r.status,4,r.stderr);assert.match(r.stderr,/--offline/);assert.ok(!existsSync(s.calls));
|
|
+});
|
|
diff --git a/scripts/discord-service.sh b/scripts/discord-service.sh
|
|
index fd2da1de1..ea726e08e 100755
|
|
--- a/scripts/discord-service.sh
|
|
+++ b/scripts/discord-service.sh
|
|
@@ -6,8 +6,8 @@
|
|
# instance per binding (mosaic-discord@<binding>). Nothing here reads the
|
|
# binding or the token. See packages/discord/README.md, "Service unit".
|
|
#
|
|
-# render print the rendered unit on stdout
|
|
-# install [--dir DIR] [--no-reload]
|
|
+# render [--copy ID [--copies DIR]] print the rendered unit on stdout
|
|
+# install [--copy ID [--copies DIR]] [--dir DIR] [--no-reload]
|
|
# write the unit (write to a temp file, then
|
|
# rename) and run `systemctl --user daemon-reload`
|
|
# uninstall [--dir DIR] [--no-reload]
|
|
@@ -19,20 +19,44 @@ set -euo pipefail
|
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
TEMPLATE="$REPO/packages/discord/systemd/[email protected]"
|
|
UNIT_NAME="[email protected]"
|
|
-USAGE="usage: scripts/discord-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>"
|
|
+USAGE="usage: scripts/discord-service.sh render [--copy ID [--copies DIR]] | install [--copy ID [--copies DIR]] [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>"
|
|
|
|
die() { echo "discord-service: $*" >&2; exit 1; }
|
|
usage() { echo "$USAGE" >&2; exit 4; }
|
|
|
|
+COPY_ID=""
|
|
+COPY_SET=0
|
|
+COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
|
|
+copy_root() {
|
|
+ [[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] || die '--copy must be a full 40-character commit id'
|
|
+ case "$COPIES" in /*) ;; *) die 'copies directory must be absolute' ;; esac
|
|
+ case "$COPIES" in *[!A-Za-z0-9/._+-]*) die 'copies directory contains a character the unit cannot carry' ;; esac
|
|
+ local root="$COPIES/$COPY_ID"
|
|
+ [ -f "$root/packages/discord/src/cli.mjs" ] || die "copy lacks packages/discord/src/cli.mjs: $root"
|
|
+ [ -f "$root/node_modules/.bin/pi" ] || die "copy lacks node_modules/.bin/pi: $root"
|
|
+ echo "$root"
|
|
+}
|
|
+
|
|
render() {
|
|
[ -f "$TEMPLATE" ] || die "template missing: $TEMPLATE"
|
|
+ local root=""
|
|
+ [ "$COPY_SET" = 0 ] || root="$(copy_root)" || return 1
|
|
local node_dir
|
|
node_dir="$(dirname "$(command -v node || true)")"
|
|
[ -n "$node_dir" ] && [ "$node_dir" != "." ] || die "node not found on PATH"
|
|
local path="/usr/local/bin:/usr/bin:/bin"
|
|
case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac
|
|
case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac
|
|
- sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" "$TEMPLATE"
|
|
+ local unit
|
|
+ unit="$(sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" "$TEMPLATE")" || return 1
|
|
+ if [ -n "$root" ]; then
|
|
+ local node
|
|
+ node="$(command -v node)"
|
|
+ case "$node" in *[!A-Za-z0-9/._+-]*) die 'node path contains a character the unit cannot carry' ;; esac
|
|
+ sed -e "s|^WorkingDirectory=.*$|WorkingDirectory=$root|" -e "s|^ExecStart=.*$|ExecStart=$node $root/packages/discord/src/cli.mjs run %i --supervised --repo $root|" <<< "$unit"
|
|
+ else
|
|
+ printf '%s\n' "$unit"
|
|
+ fi
|
|
}
|
|
|
|
# Parse --dir and --no-reload for install and uninstall.
|
|
@@ -41,8 +65,10 @@ RELOAD=1
|
|
parse_flags() {
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
- --dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; shift 2 ;;
|
|
- --no-reload) RELOAD=0; shift ;;
|
|
+ --copy) [ "$cmd" != uninstall ] && [ $# -ge 2 ] || usage; COPY_ID="$2"; COPY_SET=1; shift 2 ;;
|
|
+ --copies) [ "$cmd" != uninstall ] && [ $# -ge 2 ] || usage; COPIES="$2"; shift 2 ;;
|
|
+ --dir) [ "$cmd" != render ] && [ $# -ge 2 ] || usage; UNIT_DIR="$2"; shift 2 ;;
|
|
+ --no-reload) [ "$cmd" != render ] || usage; RELOAD=0; shift ;;
|
|
*) usage ;;
|
|
esac
|
|
done
|
|
@@ -50,10 +76,12 @@ parse_flags() {
|
|
|
|
install_unit() {
|
|
parse_flags "$@"
|
|
+ local rendered
|
|
+ rendered="$(render)" || exit 1
|
|
mkdir -p "$UNIT_DIR"
|
|
local target="$UNIT_DIR/$UNIT_NAME" tmp
|
|
tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")"
|
|
- render > "$tmp"
|
|
+ printf '%s\n' "$rendered" > "$tmp"
|
|
chmod 0644 "$tmp"
|
|
if [ -f "$target" ] && cmp -s "$tmp" "$target"; then
|
|
rm -f "$tmp"
|
|
@@ -66,6 +94,11 @@ install_unit() {
|
|
systemctl --user daemon-reload || die "daemon-reload failed"
|
|
echo "daemon-reload done"
|
|
fi
|
|
+ if [ -n "$COPY_ID" ]; then
|
|
+ echo "copy: $COPY_ID"
|
|
+ else
|
|
+ echo "checkout: $REPO (use --copy ID to pin the connector)"
|
|
+ fi
|
|
cat <<MSG
|
|
next, for one binding:
|
|
systemctl --user enable --now mosaic-discord@<binding> start now and at login
|
|
@@ -106,6 +139,17 @@ status_unit() {
|
|
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
|
|
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
|
|
systemctl --user show "$unit" -p MainPID -p NRestarts -p ExecMainStartTimestamp -p Result 2>/dev/null | sed 's/^/ /'
|
|
+ local working id
|
|
+ working="$(systemctl --user show "$unit" -p WorkingDirectory --value 2>/dev/null)" || working=""
|
|
+ # Accept property-form output too, for systemctl fixtures and older wrappers.
|
|
+ working="${working#WorkingDirectory=}"
|
|
+ echo " tree: ${working:-absent (WorkingDirectory unavailable)}"
|
|
+ id="${working##*/}"
|
|
+ if [[ "$id" =~ ^[0-9a-f]{40}$ ]] && [ -f "$working/packages/discord/src/cli.mjs" ]; then
|
|
+ echo " copy: $id"
|
|
+ elif [ -n "$working" ]; then
|
|
+ echo " checkout: $working"
|
|
+ fi
|
|
local config="${XDG_CONFIG_HOME:-$HOME/.config}/mosaic-dev/config.json"
|
|
local data_root
|
|
data_root="$(node -e 'const c=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(String(c.dataRoot||""))' "$config" 2>/dev/null || true)"
|
|
@@ -130,7 +174,7 @@ status_unit() {
|
|
[ $# -ge 1 ] || usage
|
|
cmd="$1"; shift
|
|
case "$cmd" in
|
|
- render) [ $# -eq 0 ] || usage; render ;;
|
|
+ render) parse_flags "$@"; render ;;
|
|
install) install_unit "$@" ;;
|
|
uninstall) uninstall_unit "$@" ;;
|
|
status) status_unit "$@" ;;
|
|
diff --git a/scripts/test-discord.sh b/scripts/test-discord.sh
|
|
index 9ec4ddda5..109dfdfa5 100755
|
|
--- a/scripts/test-discord.sh
|
|
+++ b/scripts/test-discord.sh
|
|
@@ -209,6 +209,62 @@ env -u USER scripts/discord-service.sh install --dir "$SANDBOX/units-nouser" --n
|
|
&& grep -qF "enable-linger $(id -un)" "$SANDBOX/install.nouser"
|
|
check "service install with USER unset finishes and names the account for lingering" $?
|
|
|
|
+# --- pinned copy service, fixture paths only ---
|
|
+COPY_ID="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
+COPIES="$SANDBOX/copies"
|
|
+COPY="$COPIES/$COPY_ID"
|
|
+mkdir -p "$COPY/packages/discord/src" "$COPY/node_modules/.bin"
|
|
+printf '// fixture\n' > "$COPY/packages/discord/src/cli.mjs"
|
|
+printf '#!/bin/sh\nexit 0\n' > "$COPY/node_modules/.bin/pi"
|
|
+chmod 755 "$COPY/node_modules/.bin/pi"
|
|
+scripts/discord-service.sh render --copy "$COPY_ID" --copies "$COPIES" >"$SANDBOX/unit.copy" 2>&1 \
|
|
+ && grep -qxF "WorkingDirectory=$COPY" "$SANDBOX/unit.copy" \
|
|
+ && grep -qxF "ExecStart=$(command -v node) $COPY/packages/discord/src/cli.mjs run %i --supervised --repo $COPY" "$SANDBOX/unit.copy" \
|
|
+ && ! sed -n '/^WorkingDirectory=/p;/^ExecStart=/p' "$SANDBOX/unit.copy" | grep -qF "$REPO_DIR"
|
|
+check "pinned render runs copy CLI with copy cwd/repo and no checkout executable" $?
|
|
+scripts/discord-service.sh install --copy "$COPY_ID" --copies "$COPIES" --dir "$SANDBOX/pinned-units" --no-reload >"$SANDBOX/pinned-install" 2>&1 \
|
|
+ && cmp -s "$SANDBOX/pinned-units/[email protected]" "$SANDBOX/unit.copy"
|
|
+check "pinned install writes the rendered unit in a temporary unit directory" $?
|
|
+for kind in short missing cli pi; do
|
|
+ id="$COPY_ID"
|
|
+ case "$kind" in short) id=short;; missing) id=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb;; cli) rm "$COPY/packages/discord/src/cli.mjs";; pi) printf '// fixture\n' > "$COPY/packages/discord/src/cli.mjs";rm "$COPY/node_modules/.bin/pi";; esac
|
|
+ scripts/discord-service.sh install --copy "$id" --copies "$COPIES" --dir "$SANDBOX/refused-$kind" --no-reload >"$SANDBOX/refused-$kind.log" 2>&1
|
|
+ rc=$?
|
|
+ [ "$rc" -eq 1 ] && [ ! -e "$SANDBOX/refused-$kind/[email protected]" ]
|
|
+ check "pinned install refuses $kind prerequisite and writes no unit" $?
|
|
+done
|
|
+printf '#!/bin/sh\nexit 0\n' > "$COPY/node_modules/.bin/pi"
|
|
+mkdir -p "$SANDBOX/fake-bin"
|
|
+cat >"$SANDBOX/fake-bin/systemctl" <<EOF_STATUS
|
|
+#!/bin/sh
|
|
+case "\$*" in *show*) echo 'WorkingDirectory=$COPY';; *)echo inactive;; esac
|
|
+EOF_STATUS
|
|
+chmod 755 "$SANDBOX/fake-bin/systemctl"
|
|
+HOME="$SANDBOX/home" XDG_CONFIG_HOME="$SANDBOX/config-home" PATH="$SANDBOX/fake-bin:$PATH" scripts/discord-service.sh status fixture >"$SANDBOX/status.copy" 2>&1 \
|
|
+ && grep -qF "copy: $COPY_ID" "$SANDBOX/status.copy" \
|
|
+ && grep -qF "tree: $COPY" "$SANDBOX/status.copy"
|
|
+check "status names the copy from fake systemctl effective cwd" $?
|
|
+grep -qF "checkout: $REPO_DIR" "$SANDBOX/install.1" && grep -qF -- '--copy' "$SANDBOX/install.1"
|
|
+check "checkout install explicitly recommends --copy" $?
|
|
+
|
|
+# Offline wrapper entrypoint. Only a synthetic token/binding/config are used.
|
|
+node - "$SANDBOX" <<'EOF_OFFLINE'
|
|
+const fs=require('fs'),p=require('path'),root=process.argv[2];
|
|
+const data=p.join(root,'offline-data');fs.mkdirSync(p.join(data,'discord'),{recursive:true});
|
|
+const b=JSON.parse(fs.readFileSync('packages/discord/fixtures/binding.example.json','utf8'));
|
|
+b.name='offline-fixture';b.tokenFile=p.join(root,'offline-token');b.context.files=['packages/discord/README.md'];delete b.tools;
|
|
+fs.writeFileSync(b.tokenFile,'MTAw.abcdefghijklmnopqrstuvwxyz0123456789\n',{mode:0o600});
|
|
+fs.writeFileSync(p.join(data,'discord',b.name+'.json'),JSON.stringify(b),{mode:0o600});
|
|
+fs.writeFileSync(p.join(root,'offline-config'),JSON.stringify({dataRoot:data}));
|
|
+fs.writeFileSync(p.join(root,'offline-trap.mjs'),`globalThis.fetch=()=>{throw Error('network forbidden in offline fixture')};globalThis.WebSocket=class{constructor(){throw Error('gateway forbidden in offline fixture')}};`);
|
|
+EOF_OFFLINE
|
|
+NODE_OPTIONS="--import=$SANDBOX/offline-trap.mjs" scripts/discord.sh check offline-fixture --config "$SANDBOX/offline-config" --offline >"$SANDBOX/offline.out" 2>&1 \
|
|
+ && grep -qF 'offline check passed; no REST or gateway calls were made' "$SANDBOX/offline.out"
|
|
+check "wrapper check --offline passes local fixture and makes no network call" $?
|
|
+NODE_OPTIONS="--import=$SANDBOX/offline-trap.mjs" scripts/discord.sh run offline-fixture --config "$SANDBOX/offline-config" --offline >"$SANDBOX/offline-run.out" 2>&1
|
|
+[ $? -eq 4 ]
|
|
+check "wrapper run --offline refuses usage" $?
|
|
+
|
|
echo
|
|
echo "discord suite: $PASS passed, $FAIL failed"
|
|
[ "$FAIL" -eq 0 ]
|