@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/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/argv.js +140 -0
- package/dist/commands/agent.js +62 -0
- package/dist/commands/apps.js +368 -0
- package/dist/commands/attachment-delete.js +37 -0
- package/dist/commands/attachment-download.js +53 -0
- package/dist/commands/attachment-list.js +50 -0
- package/dist/commands/attachment-show.js +37 -0
- package/dist/commands/attachment-token.js +133 -0
- package/dist/commands/attachment-upload.js +65 -0
- package/dist/commands/attachment.js +133 -0
- package/dist/commands/claim.js +68 -0
- package/dist/commands/config.js +262 -0
- package/dist/commands/data.js +168 -0
- package/dist/commands/deploy.js +136 -0
- package/dist/commands/feedback.js +133 -0
- package/dist/commands/key.js +82 -0
- package/dist/commands/logout.js +59 -0
- package/dist/commands/members.js +143 -0
- package/dist/commands/register.js +131 -0
- package/dist/commands/set-key.js +92 -0
- package/dist/commands/skill.js +145 -0
- package/dist/commands/taste.js +165 -0
- package/dist/config.js +165 -0
- package/dist/format.js +133 -0
- package/dist/index.js +230 -0
- package/dist/input.js +42 -0
- package/dist/output.js +77 -0
- package/dist/resolve-app.js +51 -0
- package/dist/store.js +205 -0
- package/dist/upgrade.js +115 -0
- package/dist/version.js +11 -0
- package/package.json +53 -0
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
|
+
}
|