@astrofoundry/pi-astro 0.18.2 → 0.18.3
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/agents/arcane.md +2 -1
- package/package.json +1 -1
- package/skills/arcane/SKILL.md +12 -4
- package/specialists/AGENTS.md +2 -0
- package/specialists/README.md +1 -1
- package/specialists/arcane/run.ts +47 -27
- package/specialists/wrappers.test.ts +3 -2
package/agents/arcane.md
CHANGED
|
@@ -13,8 +13,9 @@ You are the Arcane specialist. The `arcane` tool is your only way to reach Arcan
|
|
|
13
13
|
Rules:
|
|
14
14
|
|
|
15
15
|
- Read first. Run `arcane` with `["projects", "list"]` or `["containers", "list"]` before proposing a change.
|
|
16
|
-
- Project definitions of GitOps-managed projects (Compose files, `.env` layout, project folders) change only through `
|
|
16
|
+
- Project definitions of GitOps-managed projects (Compose files, `.env` layout, project folders) change only through `git show`, `git write`, `git commit`, `git push`, then the project's server-side sync (`arcane-cli gitops`). Never edit a managed project through `projects update` or `projects workspace`, even though the CLI allows it.
|
|
17
17
|
- Operational actions (restart, logs, stats, redeploy, image pull, prune) go straight through the CLI.
|
|
18
18
|
- Confirm destructive operations (`down`, `delete`, `prune`) are explicitly requested in the task before running them.
|
|
19
|
+
- Updating Arcane itself ends with a hand-over: after the repository change and the file sync, stop and give the caller the host commands from the skill. Never run `system upgrade` or `projects upgrade arcane`.
|
|
19
20
|
- Report facts from the JSON output. Sensitive fields are already removed; never guess at what was removed.
|
|
20
21
|
- Delegate through `subagent` only to another specialist (`astro.identity`, `astro.network`) and only for that specialist's own area.
|
package/package.json
CHANGED
package/skills/arcane/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: How the Arcane specialist operates the Arcane container platform an
|
|
|
5
5
|
|
|
6
6
|
# Arcane specialist
|
|
7
7
|
|
|
8
|
-
Arcane manages the Docker host on VM 100 (`10.0.40.30`). Every app is a Compose project synced from the GitOps repository `astronaute77/arcane`, branch `main`, into `/opt/docker`. The `arcane` tool wraps `arcane-cli` with a fresh short-lived token on every call and adds `
|
|
8
|
+
Arcane manages the Docker host on VM 100 (`10.0.40.30`). Every app is a Compose project synced from the GitOps repository `astronaute77/arcane`, branch `main`, into `/opt/docker`. The `arcane` tool wraps `arcane-cli` with a fresh short-lived token on every call and adds `git` subcommands for the repository checkout. `arcane-cli gitops ...` (server-side syncs) passes through untouched.
|
|
9
9
|
|
|
10
10
|
## Calling the tool
|
|
11
11
|
|
|
@@ -14,7 +14,7 @@ Arcane manages the Docker host on VM 100 (`10.0.40.30`). Every app is a Compose
|
|
|
14
14
|
```json
|
|
15
15
|
{ "args": ["projects", "list"] }
|
|
16
16
|
{ "args": ["containers", "logs", "mealie-mealie-1", "--tail", "200"] }
|
|
17
|
-
{ "args": ["
|
|
17
|
+
{ "args": ["git", "status"] }
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
`["--help"]` prints the wrapper's help. `arcane-cli` subcommands accept `--help` too, for example `["projects", "--help"]`.
|
|
@@ -22,12 +22,20 @@ Arcane manages the Docker host on VM 100 (`10.0.40.30`). Every app is a Compose
|
|
|
22
22
|
## Two kinds of operations
|
|
23
23
|
|
|
24
24
|
1. **Definitions** of GitOps-managed projects (every project whose `gitOpsManagedBy` field is set, including `arcane` itself): Compose files, env layout, new or removed projects. Only through the repository:
|
|
25
|
-
`
|
|
25
|
+
`git pull` → `git ls` / `git show <path>` to read the current files → `git write <path> <content>` → `git diff` → `git commit <message>` → `git push` → then run the project's server-side sync with `["gitops", ...]` (`["gitops", "--help"]` lists the sync commands; find the sync by project, then trigger it) → verify with `["projects", "list"]` and container logs. Editing a managed project directly (`projects update`, `projects workspace`) makes the repository lie; do not do it.
|
|
26
26
|
2. **Operations**: restart, logs, stats, redeploy, pull images, upgrade, prune. Straight through the CLI.
|
|
27
27
|
|
|
28
28
|
The full `arcane-cli` is available. The tool refuses only `config`, `auth`, `self-update`, `completion`, which would change its own setup.
|
|
29
29
|
|
|
30
|
-
Updating Arcane itself
|
|
30
|
+
## Updating Arcane itself
|
|
31
|
+
|
|
32
|
+
The `arcane` project is GitOps-managed and pinned by digest (`ghcr.io/getarcaneapp/manager@sha256:...`). The documented path (Arcane installation guide, homelab self-management exception) has three steps; you do the first two and hand over the third.
|
|
33
|
+
|
|
34
|
+
1. Repository: `git pull`, `git show arcane/compose.yaml`, find the new digest with `["projects", "updates"]`, `git write` the file with the new digest, `git diff`, `git commit "Update Arcane manager image"`, `git push`.
|
|
35
|
+
2. Sync files: trigger the `arcane` project's GitOps sync through `["gitops", ...]` so `/opt/docker/arcane/compose.yaml` matches the repository. The manager cannot recreate itself from its own sync.
|
|
36
|
+
3. Stop and report: the caller runs on VM 100, in `/opt/docker/arcane`, `docker compose pull arcane && docker compose up -d arcane`, then asks you to verify with `["version"]` and `["projects", "list"]`.
|
|
37
|
+
|
|
38
|
+
Never use `["system", "upgrade"]` or `["projects", "upgrade", "arcane"]` for the manager: both bypass the pinned digest in the repository.
|
|
31
39
|
|
|
32
40
|
## Output
|
|
33
41
|
|
package/specialists/AGENTS.md
CHANGED
|
@@ -39,6 +39,8 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
|
|
|
39
39
|
- Output filters: Arcane responses pass `stripKeys(ARCANE_DENIED_KEYS)`; Zitadel responses pass `redactSecrets`. Add keys there rather than in a wrapper.
|
|
40
40
|
- Fixed remote commands: `DMZ_COMMANDS` and `PULSAR_COMMANDS` must match the entry scripts in `entry/remote/` and their tracked copies in the homelab repository (`02-pulsar-proxmox/dmz/system/`, `02-pulsar-proxmox/pulsar/system/`). Change both sides in the same commit and redeploy the remote script.
|
|
41
41
|
- `REFUSED_PREFIXES` protects only the wrapper itself (`config`, `auth`, `self-update`, `completion`). The GitOps-first rule for managed projects lives in the skill and the agent prompt, not in code, because unmanaged projects and emergencies need the direct CLI.
|
|
42
|
+
- The wrapper's repository commands are the `git` group; `gitops` must stay free because it is an `arcane-cli` command group (server-side syncs).
|
|
43
|
+
- Every passthrough call writes a private per-call copy of `arcanecli.yml` with `jwt_token` set, because some `arcane-cli` commands validate the config file before reading `ARCANE_TOKEN`. The copy is removed after the call.
|
|
42
44
|
- Every wrapper answers `--help` without reading config, so agents can discover subcommands before anything else is set up.
|
|
43
45
|
|
|
44
46
|
## Gate
|
package/specialists/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A specialist is a Pi subagent that is the only way to operate one area of the ho
|
|
|
4
4
|
|
|
5
5
|
| Agent | Area | What it can do |
|
|
6
6
|
|---|---|---|
|
|
7
|
-
| `astro.arcane` | Arcane container platform (VM 100) and its GitOps repository | Every `arcane-cli` operation except its own `config`, `auth`, `self-update`; Compose changes of GitOps-managed projects through `
|
|
7
|
+
| `astro.arcane` | Arcane container platform (VM 100) and its GitOps repository | Every `arcane-cli` operation except its own `config`, `auth`, `self-update`; Compose changes of GitOps-managed projects through `git show|write|commit|push`, then `arcane-cli gitops` sync |
|
|
8
8
|
| `astro.identity` | Zitadel; Pomerium, nginx, lego on VM 104 | Any Zitadel API call (v2, management, admin, auth) with responses redacted; `dmz status|journal|config-template|render|pomerium-restart|nginx-reload|lego-renew` |
|
|
9
9
|
| `astro.network` | UniFi, FreeRADIUS (LXC 108), Tailscale (LXC 105) | Read UniFi sites, devices, clients, networks, firewall policies, WANs; RADIUS and Tailscale checks on Pulsar |
|
|
10
10
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
2
3
|
import { dirname, join, relative, resolve } from "node:path";
|
|
3
4
|
import { readConfig } from "../lib/config.ts";
|
|
4
5
|
import { ServiceError, UsageError } from "../lib/errors.ts";
|
|
@@ -63,18 +64,19 @@ export function refusedPrefix(args: string[]): string | null {
|
|
|
63
64
|
|
|
64
65
|
const HELP = `arcane specialist
|
|
65
66
|
|
|
66
|
-
<arcane-cli args...>
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
67
|
+
<arcane-cli args...> run arcane-cli with a fresh federated token; JSON output, sensitive fields removed
|
|
68
|
+
(arcane-cli gitops ... manages the server-side syncs)
|
|
69
|
+
git status branch, ahead/behind, changed files of the GitOps repository checkout
|
|
70
|
+
git pull fast-forward the checkout from origin
|
|
71
|
+
git ls [dir] list files in the checkout
|
|
72
|
+
git show <path> print a file from the checkout
|
|
73
|
+
git diff unstaged and staged changes
|
|
74
|
+
git log [n] last n commits (default 10)
|
|
75
|
+
git write <path> <content> write a file inside the checkout (creates folders)
|
|
76
|
+
git commit <message> stage everything and commit
|
|
77
|
+
git push push the branch to origin
|
|
76
78
|
|
|
77
|
-
Refused: ${REFUSED_PREFIXES.join(", ")}. GitOps-managed projects (gitOpsManagedBy set) change
|
|
79
|
+
Refused: ${REFUSED_PREFIXES.join(", ")}. GitOps-managed projects (gitOpsManagedBy set) change in the repository, then sync.`;
|
|
78
80
|
|
|
79
81
|
const TIMEOUT_MS = 240_000;
|
|
80
82
|
|
|
@@ -121,7 +123,7 @@ export function safeRepoPath(dir: string, rel: string): string {
|
|
|
121
123
|
return target;
|
|
122
124
|
}
|
|
123
125
|
|
|
124
|
-
async function
|
|
126
|
+
async function repo(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
125
127
|
const [sub, ...rest] = args;
|
|
126
128
|
const dir = await ensureCheckout(config);
|
|
127
129
|
switch (sub) {
|
|
@@ -141,14 +143,14 @@ async function gitops(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
|
141
143
|
printRaw((await git(dir, ["diff", "HEAD"])) || "(no changes)");
|
|
142
144
|
return 0;
|
|
143
145
|
case "ls": {
|
|
144
|
-
if (rest.length > 1) throw new UsageError("
|
|
146
|
+
if (rest.length > 1) throw new UsageError("git ls [dir]");
|
|
145
147
|
const target = rest[0] === undefined ? dir : safeRepoPath(dir, rest[0]);
|
|
146
148
|
if (!existsSync(target) || !statSync(target).isDirectory()) throw new UsageError(`not a directory in the checkout: ${rest[0] ?? "."}`);
|
|
147
149
|
printJson(readdirSync(target, { withFileTypes: true }).filter((e) => e.name !== ".git").map((e) => (e.isDirectory() ? `${e.name}/` : e.name)).sort());
|
|
148
150
|
return 0;
|
|
149
151
|
}
|
|
150
152
|
case "show": {
|
|
151
|
-
if (rest.length !== 1) throw new UsageError("
|
|
153
|
+
if (rest.length !== 1) throw new UsageError("git show <path>");
|
|
152
154
|
const target = safeRepoPath(dir, rest[0]);
|
|
153
155
|
if (!existsSync(target) || !statSync(target).isFile()) throw new UsageError(`not a file in the checkout: ${rest[0]}`);
|
|
154
156
|
printRaw(readFileSync(target, "utf-8"));
|
|
@@ -156,12 +158,12 @@ async function gitops(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
|
156
158
|
}
|
|
157
159
|
case "log": {
|
|
158
160
|
const n = rest[0] === undefined ? 10 : Number(rest[0]);
|
|
159
|
-
if (!Number.isInteger(n) || n <= 0 || n > 200) throw new UsageError("
|
|
161
|
+
if (!Number.isInteger(n) || n <= 0 || n > 200) throw new UsageError("git log [n]: n must be 1..200");
|
|
160
162
|
printRaw(await git(dir, ["log", `-n${n}`, "--format=%h %ad %s", "--date=short"]));
|
|
161
163
|
return 0;
|
|
162
164
|
}
|
|
163
165
|
case "write": {
|
|
164
|
-
if (rest.length !== 2) throw new UsageError("
|
|
166
|
+
if (rest.length !== 2) throw new UsageError("git write <path> <content>");
|
|
165
167
|
const target = safeRepoPath(dir, rest[0]);
|
|
166
168
|
mkdirSync(dirname(target), { recursive: true });
|
|
167
169
|
writeFileSync(target, rest[1].endsWith("\n") ? rest[1] : `${rest[1]}\n`, { mode: 0o600 });
|
|
@@ -170,7 +172,7 @@ async function gitops(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
|
170
172
|
}
|
|
171
173
|
case "commit": {
|
|
172
174
|
const message = rest.join(" ").trim();
|
|
173
|
-
if (message.length === 0) throw new UsageError("
|
|
175
|
+
if (message.length === 0) throw new UsageError("git commit <message>");
|
|
174
176
|
await git(dir, ["add", "--all"]);
|
|
175
177
|
const staged = (await git(dir, ["diff", "--cached", "--name-only"])).split("\n").filter(Boolean);
|
|
176
178
|
if (staged.length === 0) throw new UsageError("nothing to commit");
|
|
@@ -185,7 +187,7 @@ async function gitops(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
|
185
187
|
return 0;
|
|
186
188
|
}
|
|
187
189
|
default:
|
|
188
|
-
throw new UsageError(`unknown
|
|
190
|
+
throw new UsageError(`unknown git subcommand: ${sub ?? "(none)"}\n${HELP}`);
|
|
189
191
|
}
|
|
190
192
|
}
|
|
191
193
|
|
|
@@ -216,18 +218,36 @@ async function federatedToken(config: ArcaneConfig): Promise<string> {
|
|
|
216
218
|
return match[1];
|
|
217
219
|
}
|
|
218
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Some arcane-cli commands validate credentials in the config file before they
|
|
223
|
+
* look at ARCANE_TOKEN, so the token goes into a private per-call copy of the
|
|
224
|
+
* config as well. The copy lives for one command and is removed afterwards.
|
|
225
|
+
*/
|
|
226
|
+
function writeCallConfig(config: ArcaneConfig, token: string): string {
|
|
227
|
+
const base = readFileSync(config.arcaneCliConfig, "utf-8").replace(/^(jwt_token|api_key|refresh_token):.*$/gm, "");
|
|
228
|
+
const dir = mkdtempSync(join(tmpdir(), "arcane-run-"));
|
|
229
|
+
const file = join(dir, "arcanecli.yml");
|
|
230
|
+
writeFileSync(file, `${base.trimEnd()}\njwt_token: ${JSON.stringify(token)}\n`, { mode: 0o600 });
|
|
231
|
+
return file;
|
|
232
|
+
}
|
|
233
|
+
|
|
219
234
|
async function passthrough(config: ArcaneConfig, args: string[]): Promise<number> {
|
|
220
235
|
const refused = refusedPrefix(args);
|
|
221
236
|
if (refused) throw new UsageError(`"${refused}" is refused by the wrapper`);
|
|
222
237
|
const token = await federatedToken(config);
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
238
|
+
const callConfig = writeCallConfig(config, token);
|
|
239
|
+
try {
|
|
240
|
+
const wantsJson = args.includes("--json") || args.includes("--output");
|
|
241
|
+
const cliArgs = ["--config", callConfig, ...(wantsJson ? [] : ["--output", "json"]), "--no-color", ...args];
|
|
242
|
+
const result = await run(config.arcaneCli, cliArgs, { env: { ARCANE_TOKEN: token }, timeoutMs: TIMEOUT_MS });
|
|
243
|
+
if (result.stdout.trim().length > 0) {
|
|
244
|
+
printRaw(filterJsonText(result.stdout, (v) => stripKeys(v, ARCANE_DENIED_KEYS)));
|
|
245
|
+
}
|
|
246
|
+
if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `arcane-cli exited ${result.code}`);
|
|
247
|
+
return 0;
|
|
248
|
+
} finally {
|
|
249
|
+
rmSync(dirname(callConfig), { recursive: true, force: true });
|
|
228
250
|
}
|
|
229
|
-
if (result.code !== 0) throw new ServiceError(result.stderr.trim() || `arcane-cli exited ${result.code}`);
|
|
230
|
-
return 0;
|
|
231
251
|
}
|
|
232
252
|
|
|
233
253
|
export async function command(args: string[]): Promise<number> {
|
|
@@ -236,7 +256,7 @@ export async function command(args: string[]): Promise<number> {
|
|
|
236
256
|
return 0;
|
|
237
257
|
}
|
|
238
258
|
const config = readConfig<ArcaneConfig>(SERVICE, SHAPE);
|
|
239
|
-
if (args[0] === "
|
|
259
|
+
if (args[0] === "git") return repo(config, args.slice(1));
|
|
240
260
|
return passthrough(config, args);
|
|
241
261
|
}
|
|
242
262
|
|
|
@@ -30,8 +30,9 @@ describe("arcane wrapper", () => {
|
|
|
30
30
|
it("prints help without touching config", async () => {
|
|
31
31
|
const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
32
32
|
expect(await arcaneCommand(["--help"])).toBe(0);
|
|
33
|
-
expect(String(write.mock.calls[0][0])).toContain("
|
|
34
|
-
expect(String(write.mock.calls[0][0])).toContain("
|
|
33
|
+
expect(String(write.mock.calls[0][0])).toContain("git write");
|
|
34
|
+
expect(String(write.mock.calls[0][0])).toContain("git show");
|
|
35
|
+
expect(String(write.mock.calls[0][0])).not.toMatch(/^\s+gitops /m);
|
|
35
36
|
write.mockRestore();
|
|
36
37
|
});
|
|
37
38
|
});
|