@homespunapps/cli 1.0.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/dist/index.js ADDED
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+ // app — command-line client for the Homespun relay.
3
+ //
4
+ // Shape: uniform `homespun <noun> <verb> [options]`. Every command lives under a
5
+ // noun; nothing is a bare top-level verb. See issue #163 for the rationale
6
+ // behind the shape and the rename from the older flat layout.
7
+ //
8
+ // Config: HOMESPUN_URL and HOMESPUN_API_KEY (env), overridable with --url / --api-key.
9
+ // Multiple environments live as named profiles in
10
+ // $XDG_CONFIG_HOME/homespun/config.json; pick one with --profile or HOMESPUN_PROFILE.
11
+ // Output is JSON by default. Every noun self-documents via --help.
12
+ import { parseArgs, ArgvError } from "./argv.js";
13
+ /**
14
+ * Translate an ArgvError into the canonical `invalid_args` envelope and exit
15
+ * non-zero. The parser throws ArgvError up-front; assertKnownFlags throws it
16
+ * from inside a runner. Both paths funnel here so the on-wire shape is one.
17
+ */
18
+ function failArgvError(e) {
19
+ const error = {
20
+ code: "invalid_args",
21
+ message: e.message,
22
+ };
23
+ if (e.hint !== undefined)
24
+ error["hint"] = e.hint;
25
+ process.stderr.write(JSON.stringify({ error }) + "\n");
26
+ process.exit(1);
27
+ }
28
+ import { runAgent, agentHelp } from "./commands/agent.js";
29
+ import { runKey, keyHelp } from "./commands/key.js";
30
+ import { runTaste, tasteHelp } from "./commands/taste.js";
31
+ import { runFeedback, feedbackHelp } from "./commands/feedback.js";
32
+ import { runConfig, configHelp } from "./commands/config.js";
33
+ import { runBlob, blobHelp } from "./commands/attachment.js";
34
+ import { runSkill, skillHelp } from "./commands/skill.js";
35
+ import { runDeploy, deployHelp } from "./commands/deploy.js";
36
+ import { runApps, appsHelp } from "./commands/apps.js";
37
+ import { runData, dataHelp } from "./commands/data.js";
38
+ import { runMembers, membersHelp } from "./commands/members.js";
39
+ import { VERSION } from "./version.js";
40
+ import { HomespunApiError } from "@homespunapps/core";
41
+ import { failUpgradeRequired } from "./output.js";
42
+ const ROOT_HELP = `homespun: apps your AI builds and hosts for you and people you invite
43
+
44
+ Usage:
45
+ homespun <command> [options]
46
+
47
+ v2 app commands (operate on an App — a persistent, deployed web app):
48
+ deploy Create or redeploy an app (POST /v1/apps or
49
+ /v1/apps/:id/versions) — the create->redeploy loop.
50
+ apps App lifecycle: list | show | update | delete | wake |
51
+ watch (stream the app's change feed as JSON-lines).
52
+ data Collection row CRUD for an app: list | get | upsert |
53
+ update | delete.
54
+ members App membership management: add | list | remove
55
+ (invite/attach a member by email, list, or remove).
56
+
57
+ Other noun groups:
58
+ key YOUR agent's API key (list | revoke).
59
+ taste YOUR agent's freeform UI taste notes
60
+ (get | set | clear) — presentation preferences the agent
61
+ has learned from human feedback and reads before
62
+ generating an app.
63
+ feedback One-shot feedback to the relay operator
64
+ (create | list) — bug reports, feature requests, notes.
65
+ attachment Binary attachments (upload | download | show | list |
66
+ delete | token <mint|revoke|list>). Attachments are
67
+ scoped to an agent or an App, and can be
68
+ referenced from input_data via \`format: homespun-attachment-id\`.
69
+ agent Agent identity on this machine (register | logout).
70
+ config CLI config inspection (show).
71
+ skill The relay's SKILL.md (show | version) — auto-updating;
72
+ no API key required.
73
+
74
+ Run \`homespun <command> --help\` for command-specific options.
75
+
76
+ Config:
77
+ HOMESPUN_URL Relay base URL. Override: --url <url>
78
+ HOMESPUN_API_KEY Agent API key. Override: --api-key <key>
79
+ HOMESPUN_PROFILE Active profile name. Override: --profile <name>
80
+ 'homespun agent register' provisions the API key and saves it (with the URL) to
81
+ \${XDG_CONFIG_HOME:-~/.config}/homespun/config.json under a named profile —
82
+ afterwards commands need no env vars. Manage multiple environments
83
+ (dev/staging/prod) with 'homespun config list / use / add / rm'.
84
+
85
+ Global flags:
86
+ -h, --help Show help.
87
+ -v, --version Print version.
88
+ --profile <name> Pick a saved profile for this invocation (overrides
89
+ HOMESPUN_PROFILE and the saved 'current_profile').
90
+ --url <url> Relay base URL — bypasses profile selection entirely.
91
+ --api-key <key> Agent API key — bypasses profile selection entirely.
92
+
93
+ Output: stdout is machine-readable JSON; errors go to stderr as
94
+ {"error":{"code","message"}} with a non-zero exit.`;
95
+ // Flags that never take a value. `json` is kept here purely for forward-compat
96
+ // (JSON is currently the only output mode): accepting `--json` as a no-op bool
97
+ // means a future `--text`/`--json` toggle won't break existing invocations. It
98
+ // is intentionally undocumented in --help.
99
+ //
100
+ // `version` is deliberately NOT here: the top-level `-v` / `--version` is
101
+ // handled from rawArgv[0] before parseArgs runs, so it never needs to be a
102
+ // boolean flag — and keeping it out leaves room for a future noun-level
103
+ // `--version <n>` value flag without a collision.
104
+ const BOOLEAN_FLAGS = new Set([
105
+ "json",
106
+ "once",
107
+ "help",
108
+ "print-key",
109
+ "yes",
110
+ "plain",
111
+ // `homespun deploy --force` / `homespun apps ... --force`: override a compat gate.
112
+ "force",
113
+ ]);
114
+ async function main() {
115
+ const rawArgv = process.argv.slice(2);
116
+ // Version: handle before anything else.
117
+ if (rawArgv[0] === "-v" || rawArgv[0] === "--version") {
118
+ process.stdout.write(VERSION + "\n");
119
+ return;
120
+ }
121
+ const noun = rawArgv[0];
122
+ const rest = rawArgv.slice(1);
123
+ if (noun === undefined ||
124
+ noun === "-h" ||
125
+ noun === "--help" ||
126
+ noun === "help") {
127
+ process.stdout.write(ROOT_HELP + "\n");
128
+ return;
129
+ }
130
+ let args;
131
+ try {
132
+ args = parseArgs(rest, BOOLEAN_FLAGS);
133
+ }
134
+ catch (e) {
135
+ if (e instanceof ArgvError) {
136
+ failArgvError(e);
137
+ }
138
+ throw e;
139
+ }
140
+ const helps = {
141
+ key: keyHelp,
142
+ taste: tasteHelp,
143
+ feedback: feedbackHelp,
144
+ attachment: blobHelp,
145
+ agent: agentHelp,
146
+ config: configHelp,
147
+ skill: skillHelp,
148
+ deploy: deployHelp,
149
+ apps: appsHelp,
150
+ data: dataHelp,
151
+ members: membersHelp,
152
+ };
153
+ if (!(noun in helps)) {
154
+ process.stderr.write(JSON.stringify({
155
+ error: {
156
+ code: "unknown_command",
157
+ message: `unknown command '${noun}' — run 'homespun --help'`,
158
+ },
159
+ }) + "\n");
160
+ process.exit(1);
161
+ }
162
+ // `homespun <noun> --help` with no verb prints the noun-level help. A verb-level
163
+ // --help is the responsibility of each runner (e.g. runApp dispatches to
164
+ // the verb runner which reads its own xxxHelp). This pre-empt only fires
165
+ // when --help is the FIRST positional-equivalent — i.e. no verb given.
166
+ if (args.bools.has("help") && args.positionals.length === 0) {
167
+ process.stdout.write(helps[noun] + "\n");
168
+ return;
169
+ }
170
+ switch (noun) {
171
+ case "key":
172
+ await runKey(args);
173
+ break;
174
+ case "taste":
175
+ await runTaste(args);
176
+ break;
177
+ case "feedback":
178
+ await runFeedback(args);
179
+ break;
180
+ case "attachment":
181
+ await runBlob(args);
182
+ break;
183
+ case "agent":
184
+ await runAgent(args);
185
+ break;
186
+ case "config":
187
+ await runConfig(args);
188
+ break;
189
+ case "skill":
190
+ await runSkill(args);
191
+ break;
192
+ case "deploy":
193
+ await runDeploy(args);
194
+ break;
195
+ case "apps":
196
+ await runApps(args);
197
+ break;
198
+ case "data":
199
+ await runData(args);
200
+ break;
201
+ case "members":
202
+ await runMembers(args);
203
+ break;
204
+ }
205
+ }
206
+ main().catch((err) => {
207
+ // ArgvError thrown from a runner (e.g. assertKnownFlags) reaches here —
208
+ // funnel it through the same invalid_args envelope as the parse-time path
209
+ // so unknown-flag rejection looks identical no matter which layer caught
210
+ // the user error.
211
+ if (err instanceof ArgvError) {
212
+ failArgvError(err);
213
+ }
214
+ // Funnel 426 cli_upgrade_required through the dedicated upgrade-message
215
+ // path so a command that throws raw (instead of going through
216
+ // failFromError) still produces the exact stderr block + exit 75 the
217
+ // SKILL.md tells the agent's harness to expect.
218
+ if (err instanceof HomespunApiError &&
219
+ err.code === "cli_upgrade_required" &&
220
+ err.status === 426) {
221
+ failUpgradeRequired(err);
222
+ }
223
+ process.stderr.write(JSON.stringify({
224
+ error: {
225
+ code: "internal",
226
+ message: err instanceof Error ? err.message : String(err),
227
+ },
228
+ }) + "\n");
229
+ process.exit(1);
230
+ });
package/dist/input.js ADDED
@@ -0,0 +1,42 @@
1
+ // Helpers for reading CLI inputs that may be either a file path or an inline
2
+ // literal (JSON, or raw text for an HTML template body).
3
+ import { readFileSync, statSync } from "node:fs";
4
+ /**
5
+ * True if `value` names an existing file. Only a missing path (ENOENT) is
6
+ * treated as "not a file" — any other fs error (EACCES, ELOOP, …) propagates
7
+ * with a labeled message rather than being misreported as inline content.
8
+ */
9
+ function isFilePath(value) {
10
+ try {
11
+ return statSync(value).isFile();
12
+ }
13
+ catch (e) {
14
+ if (e &&
15
+ typeof e === "object" &&
16
+ e.code === "ENOENT") {
17
+ return false;
18
+ }
19
+ const code = e && typeof e === "object" ? e.code : undefined;
20
+ throw new Error(`cannot stat '${value}'${code ? ` (${code})` : ""}: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
21
+ }
22
+ }
23
+ /**
24
+ * Resolve a value that is either a file path or an inline JSON literal.
25
+ * Returns the parsed JSON. Throws on parse failure.
26
+ */
27
+ export function resolveJson(value, label) {
28
+ const raw = isFilePath(value) ? readFileSync(value, "utf8") : value;
29
+ try {
30
+ return JSON.parse(raw);
31
+ }
32
+ catch (e) {
33
+ throw new Error(`${label}: not valid JSON (${e instanceof Error ? e.message : String(e)})`, { cause: e });
34
+ }
35
+ }
36
+ /**
37
+ * Resolve raw text that is either a file path or an inline literal — no JSON
38
+ * parsing. Used for an inline HTML template body.
39
+ */
40
+ export function resolveText(value) {
41
+ return isFilePath(value) ? readFileSync(value, "utf8") : value;
42
+ }
package/dist/output.js ADDED
@@ -0,0 +1,77 @@
1
+ // stdout/stderr helpers. The CLI is JSON-by-default: machine-readable on
2
+ // stdout, human errors on stderr.
3
+ import { HomespunApiError } from "@homespunapps/core";
4
+ import { fileURLToPath } from "node:url";
5
+ import { detectInstallMethod, upgradeCommandFor, formatUpgradeMessage, EXIT_CLI_UPGRADE_REQUIRED, } from "./upgrade.js";
6
+ /** Print a value as pretty JSON to stdout. */
7
+ export function printJson(value) {
8
+ process.stdout.write(JSON.stringify(value, null, 2) + "\n");
9
+ }
10
+ /**
11
+ * Print a single compact JSON line to stdout and flush. Used by `homespun watch`
12
+ * so a pipe-reader (e.g. Claude Code's Monitor tool) sees each event
13
+ * immediately, one event per line.
14
+ */
15
+ export function printJsonLine(value) {
16
+ process.stdout.write(JSON.stringify(value) + "\n");
17
+ }
18
+ /** Print an error envelope to stderr and exit non-zero. */
19
+ export function fail(message, code = "error", details, extra) {
20
+ const error = { code, message };
21
+ if (extra?.hint !== undefined)
22
+ error["hint"] = extra.hint;
23
+ if (extra?.retryable !== undefined)
24
+ error["retryable"] = extra.retryable;
25
+ if (extra?.docs_url !== undefined)
26
+ error["docs_url"] = extra.docs_url;
27
+ if (details !== undefined)
28
+ error["details"] = details;
29
+ process.stderr.write(JSON.stringify({ error }) + "\n");
30
+ process.exit(1);
31
+ }
32
+ /** Translate a thrown error (incl. HomespunApiError) into a fail() exit. */
33
+ export function failFromError(err) {
34
+ // 426 cli_upgrade_required gets its own dedicated exit path: a
35
+ // human-readable upgrade message on stderr and a stable exit code
36
+ // (sysexits EX_TEMPFAIL = 75) that the SKILL.md instructs the agent's
37
+ // harness to branch on. Everything else falls through to the generic
38
+ // JSON envelope below.
39
+ if (err instanceof HomespunApiError &&
40
+ err.code === "cli_upgrade_required" &&
41
+ err.status === 426) {
42
+ failUpgradeRequired(err);
43
+ }
44
+ if (err instanceof HomespunApiError) {
45
+ fail(err.message, err.code, err.details, {
46
+ hint: err.hint,
47
+ retryable: err.retryable,
48
+ docs_url: err.docsUrl,
49
+ });
50
+ }
51
+ fail(err instanceof Error ? err.message : String(err), "internal");
52
+ }
53
+ /**
54
+ * Print the upgrade message to stderr and exit 75. Pulled out of
55
+ * failFromError so the top-level main().catch can also funnel through it
56
+ * — the two entry points must produce identical output for the SKILL.md's
57
+ * "if you see exit 75…" instructions to be reliable.
58
+ *
59
+ * The install-method detection reads `import.meta.url` of the CLI entry,
60
+ * resolved from the call site that imports this module. Inlining the
61
+ * resolution here keeps each command's own error-handling free of the
62
+ * detail.
63
+ */
64
+ export function failUpgradeRequired(err) {
65
+ // The CLI entry is packages/cli/dist/index.js (after build) or
66
+ // packages/cli/src/index.ts (when running from source via tsx). Either
67
+ // way, the detector only looks at the path's shape, so resolving from
68
+ // *this* file works — output.ts sits alongside index.ts/index.js in
69
+ // both layouts.
70
+ const entryPath = fileURLToPath(import.meta.url);
71
+ const method = detectInstallMethod(entryPath);
72
+ const details = (err.details ?? {});
73
+ const minVersion = typeof details.min_version === "string" ? details.min_version : "0.0.0";
74
+ const command = upgradeCommandFor(method, minVersion);
75
+ process.stderr.write(formatUpgradeMessage(err, method, command) + "\n");
76
+ process.exit(EXIT_CLI_UPGRADE_REQUIRED);
77
+ }
@@ -0,0 +1,51 @@
1
+ // Shared `<app>` resolution for every v2 CLI noun (`apps`, `data`, `deploy
2
+ // --app`): accepts either the App.id (cuid) or its slug, resolving a slug to
3
+ // an id via `GET /v1/apps?slug=` — spec-cli §3.2's "the single ergonomic
4
+ // concession beyond the raw /v1 shape."
5
+ import { HomespunApiError } from "@homespunapps/core";
6
+ import { fail } from "./output.js";
7
+ // cuid2 ids (Prisma's `@default(cuid())`) are lowercase alphanumeric,
8
+ // starting with a letter, 24+ chars. A slug is DNS-label-shaped (spec-schema
9
+ // §6 ruling 8) and always contains at least one hyphen in practice
10
+ // (`<adjective>-<noun>-<suffix>` for generated ones, or a short owner-chosen
11
+ // word) — but the deciding heuristic here is simply "looks like a cuid";
12
+ // anything else is treated as a slug and resolved via a lookup.
13
+ //
14
+ // This is a heuristic, NOT a proof: `public`/`private` apps allow any
15
+ // DNS-label-shaped owner-chosen slug (app-slug.ts's SLUG_RX), hyphens
16
+ // optional, so a 21+-char hyphen-free slug also matches CUID_RX. Rather than
17
+ // trust the shape alone, `resolveAppId` below verifies an id-shaped value
18
+ // against `GET /v1/apps/:id` and falls back to the slug lookup on a 404 —
19
+ // so a legit slug always resolves regardless of how it happens to be shaped.
20
+ const CUID_RX = /^[a-z][a-z0-9]{20,}$/;
21
+ function looksLikeId(value) {
22
+ return CUID_RX.test(value);
23
+ }
24
+ /**
25
+ * Resolve a CLI-supplied `<app>` positional to an App.id. Values that look
26
+ * like a cuid are tried as-is via `GET /v1/apps/:id` first (the fast, common
27
+ * path); if that 404s — e.g. an owner-chosen slug that happens to be
28
+ * hyphen-free and 21+ chars, matching the cuid heuristic — falls back to
29
+ * `GET /v1/apps?slug=`. Values that don't look like a cuid skip straight to
30
+ * the slug lookup. Fails with `app_not_found` if no app matches either way.
31
+ */
32
+ export async function resolveAppId(client, value) {
33
+ if (looksLikeId(value)) {
34
+ try {
35
+ await client.getApp(value);
36
+ return value;
37
+ }
38
+ catch (e) {
39
+ if (!(e instanceof HomespunApiError && e.code === "app_not_found")) {
40
+ throw e;
41
+ }
42
+ // Fall through: treat it as a slug instead.
43
+ }
44
+ }
45
+ const page = await client.listApps({ status: "all", slug: value, limit: 1 });
46
+ const match = page.items[0];
47
+ if (!match) {
48
+ fail(`no app found with slug '${value}'`, "app_not_found");
49
+ }
50
+ return match.id;
51
+ }
package/dist/store.js ADDED
@@ -0,0 +1,205 @@
1
+ // Persisted CLI config: ${XDG_CONFIG_HOME or ~/.config}/homespun/config.json.
2
+ //
3
+ // Holds one or more named profiles. Each profile is one agent identity on
4
+ // one relay — (url, api_key). Switching profiles is the multi-environment
5
+ // story: dev / staging / prod, or personal / work agents on the same relay,
6
+ // without re-running `homespun agent register` between them.
7
+ //
8
+ // On-disk shape:
9
+ //
10
+ // {
11
+ // "current_profile": "prod",
12
+ // "profiles": {
13
+ // "prod": { "url": "https://…", "api_key": "hs_…" },
14
+ // "dev": { "url": "http://localhost:3000", "api_key": "hs_…" }
15
+ // }
16
+ // }
17
+ //
18
+ // Tiny and synchronous; no deps. Holds secrets — files written mode 0600.
19
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync, } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join, dirname } from "node:path";
22
+ /**
23
+ * Default profile name when the user runs `homespun agent register` without
24
+ * `--profile` on a fresh install. Stable, predictable, and short enough to
25
+ * type in `homespun --profile default …` if needed.
26
+ */
27
+ export const DEFAULT_PROFILE_NAME = "default";
28
+ /** Profile-name validation (a-z, A-Z, 0-9, _ and -, 1..32 chars). */
29
+ const PROFILE_NAME_RX = /^[A-Za-z0-9_-]{1,32}$/;
30
+ export function isValidProfileName(name) {
31
+ return PROFILE_NAME_RX.test(name);
32
+ }
33
+ /** Absolute path to the config file (honours XDG_CONFIG_HOME). */
34
+ export function storePath() {
35
+ const base = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim() !== ""
36
+ ? process.env.XDG_CONFIG_HOME
37
+ : join(homedir(), ".config");
38
+ return join(base, "homespun", "config.json");
39
+ }
40
+ /**
41
+ * Read the persisted config. Returns an empty store if the file is missing,
42
+ * unparseable, or doesn't carry a `profiles` object.
43
+ */
44
+ export function readStore() {
45
+ let text;
46
+ try {
47
+ text = readFileSync(storePath(), "utf8");
48
+ }
49
+ catch {
50
+ return { profiles: {} };
51
+ }
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(text);
55
+ }
56
+ catch {
57
+ return { profiles: {} };
58
+ }
59
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
60
+ return { profiles: {} };
61
+ }
62
+ const obj = parsed;
63
+ if (!obj["profiles"] || typeof obj["profiles"] !== "object") {
64
+ return { profiles: {} };
65
+ }
66
+ const rawProfiles = obj["profiles"];
67
+ const profiles = {};
68
+ for (const [name, raw] of Object.entries(rawProfiles)) {
69
+ if (raw === null || typeof raw !== "object")
70
+ continue;
71
+ const p = raw;
72
+ const profile = {};
73
+ if (typeof p["url"] === "string")
74
+ profile.url = p["url"];
75
+ if (typeof p["api_key"] === "string")
76
+ profile.apiKey = p["api_key"];
77
+ profiles[name] = profile;
78
+ }
79
+ const currentProfile = typeof obj["current_profile"] === "string"
80
+ ? obj["current_profile"]
81
+ : undefined;
82
+ // If the named current profile was deleted out-of-band, drop it back to
83
+ // undefined so the resolver can fall through to env / default URL.
84
+ return {
85
+ currentProfile: currentProfile && profiles[currentProfile] !== undefined
86
+ ? currentProfile
87
+ : undefined,
88
+ profiles,
89
+ };
90
+ }
91
+ /** Serialise a Store to the on-disk JSON shape (snake_case fields). */
92
+ function serialize(store) {
93
+ const profilesOut = {};
94
+ for (const [name, p] of Object.entries(store.profiles)) {
95
+ const o = {};
96
+ if (p.url !== undefined)
97
+ o["url"] = p.url;
98
+ if (p.apiKey !== undefined)
99
+ o["api_key"] = p.apiKey;
100
+ profilesOut[name] = o;
101
+ }
102
+ const body = { profiles: profilesOut };
103
+ if (store.currentProfile !== undefined) {
104
+ body["current_profile"] = store.currentProfile;
105
+ }
106
+ return JSON.stringify(body, null, 2) + "\n";
107
+ }
108
+ /**
109
+ * Atomically write the whole Store to disk. The file is created with mode
110
+ * 0600 and the parent directory is created as needed.
111
+ */
112
+ export function writeStoreFull(store) {
113
+ const path = storePath();
114
+ mkdirSync(dirname(path), { recursive: true });
115
+ writeFileSync(path, serialize(store), { mode: 0o600 });
116
+ // Ensure mode even when the file pre-existed with looser permissions.
117
+ chmodSync(path, 0o600);
118
+ return path;
119
+ }
120
+ /**
121
+ * Upsert a single profile and write back. If `setCurrent` is true, the
122
+ * profile becomes the active one. If the store had no current profile yet
123
+ * (empty store), the newly-written profile becomes current regardless —
124
+ * there's no other choice that makes sense.
125
+ */
126
+ export function upsertProfile(name, patch, setCurrent = false) {
127
+ if (!isValidProfileName(name)) {
128
+ throw new Error(`invalid profile name '${name}' — must match ${PROFILE_NAME_RX} (letters, digits, underscore, dash; 1..32 chars)`);
129
+ }
130
+ const store = readStore();
131
+ const merged = { ...(store.profiles[name] ?? {}), ...patch };
132
+ store.profiles[name] = merged;
133
+ if (setCurrent || store.currentProfile === undefined) {
134
+ store.currentProfile = name;
135
+ }
136
+ return writeStoreFull(store);
137
+ }
138
+ /**
139
+ * Set the active profile by name. Throws if `name` is not in the store.
140
+ * Use `upsertProfile` if you also want to create it.
141
+ */
142
+ export function setCurrentProfile(name) {
143
+ const store = readStore();
144
+ if (store.profiles[name] === undefined) {
145
+ throw new Error(`profile '${name}' does not exist — run 'homespun config list' to see available profiles`);
146
+ }
147
+ store.currentProfile = name;
148
+ return writeStoreFull(store);
149
+ }
150
+ /**
151
+ * Remove a profile. If it was current, drop `current_profile` (the resolver
152
+ * falls through to env / default URL). If the resulting store is empty,
153
+ * delete the file entirely so a `readStore` looks identical to "fresh".
154
+ * Returns `{ path, was_current }`. Throws if the profile doesn't exist.
155
+ */
156
+ export function removeProfile(name) {
157
+ const store = readStore();
158
+ if (store.profiles[name] === undefined) {
159
+ throw new Error(`profile '${name}' does not exist`);
160
+ }
161
+ const wasCurrent = store.currentProfile === name;
162
+ delete store.profiles[name];
163
+ if (wasCurrent) {
164
+ store.currentProfile = undefined;
165
+ }
166
+ if (Object.keys(store.profiles).length === 0) {
167
+ // Empty store → delete the file so a subsequent register starts fresh.
168
+ return { path: clearStore(), was_current: wasCurrent };
169
+ }
170
+ return { path: writeStoreFull(store), was_current: wasCurrent };
171
+ }
172
+ /**
173
+ * Delete the persisted config file entirely. Idempotent — no error if the
174
+ * file never existed. Returns the path it targeted. Used by
175
+ * `homespun agent logout --all` and `removeProfile` when it drains the last
176
+ * profile.
177
+ */
178
+ export function clearStore() {
179
+ const path = storePath();
180
+ rmSync(path, { force: true });
181
+ return path;
182
+ }
183
+ /**
184
+ * Resolve which profile to load from the store, given the optional selector
185
+ * (`--profile` flag or `HOMESPUN_PROFILE` env). Returns `null` if no profile
186
+ * matches — i.e. the caller should fall through to env / default-URL
187
+ * resolution. Throws if `selector` was explicit (truthy) and not found, so
188
+ * a typo in `--profile dev` doesn't silently fall back to the wrong relay.
189
+ */
190
+ export function resolveProfile(store, selector) {
191
+ if (selector !== undefined && selector !== "") {
192
+ const p = store.profiles[selector];
193
+ if (p === undefined) {
194
+ const known = Object.keys(store.profiles).sort().join(", ") || "(none)";
195
+ throw new Error(`profile '${selector}' does not exist (known: ${known}) — run 'homespun config list'`);
196
+ }
197
+ return { name: selector, profile: p };
198
+ }
199
+ if (store.currentProfile !== undefined) {
200
+ const p = store.profiles[store.currentProfile];
201
+ if (p !== undefined)
202
+ return { name: store.currentProfile, profile: p };
203
+ }
204
+ return null;
205
+ }