@homespunapps/cli 1.0.0 → 1.0.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @homespunapps/cli
2
2
 
3
- Command-line client for the [Homespun](https://github.com/aerolalit/homespun) relay:
3
+ Command-line client for the [Homespun](https://homespun.dev) relay:
4
4
  hand a human a rich interactive UI by URL and capture their answer as structured
5
5
  data — from any agent (cron job, chat bot, CI, headless server).
6
6
 
@@ -30,10 +30,19 @@ Add `--no-open` on a headless / SSH box and it just prints the URL.
30
30
  ## Setup
31
31
 
32
32
  ```sh
33
- export HOMESPUN_URL=https://homespun.dev # or your self-hosted relay
33
+ export HOMESPUN_URL=https://homespun.dev # or a different relay origin
34
34
  homespun agent register --name "my-agent" # provisions and saves an API key
35
35
  ```
36
36
 
37
+ By default `homespun agent register` uses browser approval (an RFC 8628 style
38
+ device flow): it prints a link and a short code like `ABCD-EFGH`; open the
39
+ link on any device (your phone works), sign in, and approve. The agent comes
40
+ out already linked to your account, ready to deploy. On an older relay
41
+ without the flow the CLI falls back to direct registration automatically
42
+ (such agents need a one-time `homespun agent claim <code>` afterwards; mint
43
+ the code in the relay's Settings). `--no-device` forces the direct path,
44
+ and `--secret <s>` (for `REGISTRATION_MODE=secret` relays) implies it.
45
+
37
46
  `homespun agent register` writes the URL + API key to
38
47
  `${XDG_CONFIG_HOME:-~/.config}/homespun/config.json`. Subsequent commands need
39
48
  only `HOMESPUN_URL` (or nothing) in the environment.
@@ -46,7 +55,9 @@ Uniform `homespun <noun> <verb> [options]`:
46
55
 
47
56
  ```
48
57
  homespun demo Zero-setup guided tour — see the round-trip live
49
- homespun agent register Provision an agent API key and save it locally
58
+ homespun agent register Provision an agent API key (browser approval
59
+ by default; --no-device for direct) and save it
60
+ homespun agent claim <code> Bind this agent to a human via a one-shot code
50
61
  homespun agent logout Clear the locally-saved URL + API key
51
62
  homespun create Create an app — returns app_id, urls, tokens
52
63
  homespun show <id> Non-blocking snapshot: metadata + event log
@@ -76,6 +87,5 @@ homespun watch "$SESSION" | jq 'select(.type == "human_response")'
76
87
 
77
88
  ## Links
78
89
 
79
- - Repo: <https://github.com/aerolalit/homespun>
80
- - Spec: <https://github.com/aerolalit/homespun/blob/main/docs/SPEC.md>
90
+ - Docs: <https://docs.homespun.dev>
81
91
  - License: MIT
@@ -1,26 +1,46 @@
1
- // `homespun agent register` self-provision an agent API key from the relay.
1
+ // `homespun agent register` - provision an agent API key from the relay.
2
2
  //
3
3
  // This is the one command that needs no API key: it is the call that obtains
4
- // one. If the relay runs REGISTRATION_MODE=secret, pass the shared
5
- // registration secret via --secret or HOMESPUN_REGISTER_SECRET. On success the key
6
- // (and relay URL) are persisted under a named profile in the CLI config file,
7
- // so every later command works with only HOMESPUN_URL (or nothing) set.
4
+ // one. Two paths:
5
+ //
6
+ // DEVICE FLOW (default) - RFC 8628 style browser approval. The CLI asks the
7
+ // relay for a device_code + user_code pair, prints a verification URL the
8
+ // human can open on ANY device, and polls until the human approves. The
9
+ // resulting agent is already OWNED by the approving human (no separate
10
+ // `homespun agent claim` step needed).
11
+ //
12
+ // DIRECT (fallback) - plain POST /v1/register, the pre-device-flow path.
13
+ // Used when the relay 404s the device endpoints (older relay), when a
14
+ // registration secret is supplied (REGISTRATION_MODE=secret relays), or on
15
+ // --no-device. Direct-registered agents are unowned until claimed.
16
+ //
17
+ // On success the key (and relay URL) are persisted under a named profile in
18
+ // the CLI config file, so every later command works with only HOMESPUN_URL (or
19
+ // nothing) set.
20
+ import { hostname } from "node:os";
8
21
  import { registerAgent, HomespunApiError } from "@homespunapps/core";
9
22
  import { assertKnownFlags } from "../argv.js";
10
23
  import { DEFAULT_RELAY_URL } from "../config.js";
24
+ import { runDeviceFlow } from "../device-flow.js";
11
25
  import { printJson, fail, failUpgradeRequired } from "../output.js";
12
26
  import { isValidProfileName, DEFAULT_PROFILE_NAME, readStore, resolveProfile, upsertProfile, } from "../store.js";
13
27
  import { VERSION } from "../version.js";
14
28
  const KNOWN_FLAGS = ["name", "secret"];
15
- const KNOWN_BOOLS = ["print-key"];
16
- export const registerHelp = `homespun agent register register this agent with the relay and save the key locally
29
+ const KNOWN_BOOLS = ["print-key", "no-device"];
30
+ export const registerHelp = `homespun agent register - register this agent with the relay and save the key locally
17
31
 
18
32
  Usage:
19
33
  homespun agent register [options]
20
34
 
21
- Calls POST /v1/register, then saves the returned API key (and relay URL) under
22
- a named profile in the CLI config file so afterwards every other command
23
- works with only HOMESPUN_URL set (no HOMESPUN_API_KEY needed).
35
+ By default this runs the browser device-authorization flow: it prints a link
36
+ and a short code, you (or the account owner) open the link on any device,
37
+ sign in, and approve - and the agent comes out already linked to that
38
+ account. Older relays without the flow fall back to plain POST /v1/register
39
+ automatically (such agents need a separate 'homespun agent claim' afterwards).
40
+
41
+ The returned API key (and relay URL) are saved under a named profile in the
42
+ CLI config file - so afterwards every other command works with only HOMESPUN_URL
43
+ set (no HOMESPUN_API_KEY needed).
24
44
 
25
45
  If --profile is omitted, the registered key goes under the currently-active
26
46
  profile (or 'default' for a fresh install). Pass --profile <name> to keep
@@ -28,8 +48,9 @@ multiple environments (dev/staging/prod) side by side; switch between them
28
48
  with 'homespun config use <name>' or '--profile <name>' / HOMESPUN_PROFILE.
29
49
 
30
50
  Options:
31
- --name <n> Agent display name on the relay. The relay defaults it
32
- if omitted.
51
+ --name <n> Agent display name on the relay (shown on the approval
52
+ screen). Defaults to cli-<hostname> for the device flow;
53
+ the relay defaults it for the direct path.
33
54
  --profile <name> Local profile name to save under. Defaults to the active
34
55
  profile, or 'default' on a fresh install. Letters,
35
56
  digits, _ and -, up to 32 chars.
@@ -37,23 +58,48 @@ Options:
37
58
  profile, then the hosted Homespun relay. Self-hosters set
38
59
  this.
39
60
  --secret <s> Registration secret, sent as a Bearer token. Only needed
40
- when the relay uses REGISTRATION_MODE=secret. Falls back
41
- to the HOMESPUN_REGISTER_SECRET env var.
61
+ when the relay uses REGISTRATION_MODE=secret. Implies the
62
+ direct path (no browser approval). Falls back to the
63
+ HOMESPUN_REGISTER_SECRET env var.
64
+ --no-device Skip the browser approval and register directly
65
+ (POST /v1/register). The agent is unowned until claimed.
42
66
  --print-key Also echo the full api_key in the output. By default the
43
67
  key is only persisted to the config file, never printed.
44
68
  -h, --help Show this help.
45
69
 
46
70
  Output (stdout, JSON):
47
- { agent_id, key_prefix, profile, saved_to } (+ api_key when --print-key)
71
+ { agent_id, key_prefix, profile, saved_to, registered_via }
72
+ (+ api_key when --print-key)
48
73
 
49
74
  The API key is saved to the CLI config file (mode 0600); it is not printed
50
75
  unless --print-key is passed.`;
76
+ /**
77
+ * Default agent name for the device flow: the consent screen must name what
78
+ * the human is approving, so an unnamed agent gets "cli-<hostname>" instead
79
+ * of the relay's unhelpful generic default. Control characters are stripped
80
+ * and the result clamped to the relay's 64-char cap.
81
+ */
82
+ export function defaultDeviceAgentName(host = hostname()) {
83
+ let cleaned = "";
84
+ for (const ch of host.trim()) {
85
+ const codePoint = ch.codePointAt(0) ?? 0;
86
+ if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f))
87
+ continue;
88
+ cleaned += ch;
89
+ }
90
+ const name = `cli-${cleaned}`.slice(0, 64).trim();
91
+ return name.length > "cli-".length ? name : "cli-agent";
92
+ }
93
+ /** Compute the display prefix of an API key, mirroring the relay's rule. */
94
+ function apiKeyPrefix(key) {
95
+ return key.startsWith("hs_") ? key.slice(0, 9) : key.slice(0, 8);
96
+ }
51
97
  export async function runRegister(args) {
52
98
  assertKnownFlags(args, KNOWN_FLAGS, KNOWN_BOOLS, "homespun agent register");
53
99
  // Profile selection for the WRITE side: --profile flag → HOMESPUN_PROFILE env
54
100
  // → the store's current profile → DEFAULT_PROFILE_NAME ('default') for
55
101
  // a fresh install. We deliberately don't fall through to "no profile, use
56
- // a fresh name" the agent needs to end up somewhere callable, and
102
+ // a fresh name" - the agent needs to end up somewhere callable, and
57
103
  // 'default' is a stable, predictable home.
58
104
  const profileFlag = args.flags.get("profile") ?? process.env.HOMESPUN_PROFILE;
59
105
  const store = readStore();
@@ -61,7 +107,7 @@ export async function runRegister(args) {
61
107
  ? profileFlag
62
108
  : (store.currentProfile ?? DEFAULT_PROFILE_NAME);
63
109
  if (!isValidProfileName(profileName)) {
64
- fail(`invalid profile name '${profileName}' letters, digits, _ and -, up to 32 chars`, "invalid_args");
110
+ fail(`invalid profile name '${profileName}' - letters, digits, _ and -, up to 32 chars`, "invalid_args");
65
111
  }
66
112
  // URL precedence for the relay we're registering against:
67
113
  // --url flag > HOMESPUN_URL env > target-profile's existing url > default.
@@ -74,21 +120,70 @@ export async function runRegister(args) {
74
120
  activeUrl = active?.profile.url;
75
121
  }
76
122
  catch {
77
- // Selector didn't resolve fine on register: we're about to create it.
123
+ // Selector didn't resolve - fine on register: we're about to create it.
78
124
  activeUrl = undefined;
79
125
  }
80
- const url = args.flags.get("url") ??
126
+ const url = (args.flags.get("url") ??
81
127
  process.env.HOMESPUN_URL ??
82
128
  activeUrl ??
83
- DEFAULT_RELAY_URL;
129
+ DEFAULT_RELAY_URL).replace(/\/$/, "");
84
130
  const name = args.flags.get("name");
85
131
  const secret = args.flags.get("secret") ??
86
132
  process.env.HOMESPUN_REGISTER_SECRET ??
87
133
  undefined;
134
+ // The device flow is the default. A registration secret implies a
135
+ // REGISTRATION_MODE=secret relay whose operator hands out direct access,
136
+ // and --no-device is the explicit opt-out (CI, headless-with-no-human).
137
+ const wantDevice = !args.bools.has("no-device") && (secret === undefined || secret === "");
138
+ if (wantDevice) {
139
+ try {
140
+ const outcome = await runDeviceFlow({
141
+ url,
142
+ name: name ?? defaultDeviceAgentName(),
143
+ cliVersion: VERSION,
144
+ });
145
+ if (outcome.supported) {
146
+ const savedTo = upsertProfile(profileName, { url, apiKey: outcome.agent_key }, true);
147
+ const out = {
148
+ agent_id: outcome.agent_id,
149
+ key_prefix: apiKeyPrefix(outcome.agent_key),
150
+ profile: profileName,
151
+ saved_to: savedTo,
152
+ registered_via: "device",
153
+ };
154
+ if (args.bools.has("print-key")) {
155
+ out["api_key"] = outcome.agent_key;
156
+ }
157
+ printJson(out);
158
+ return;
159
+ }
160
+ // 404 on /v1/device/code: an older relay. Fall through to the direct
161
+ // path with a note so the behavior change is visible, not silent.
162
+ process.stderr.write("note: this relay does not support browser approval (older relay); " +
163
+ "falling back to direct registration. The agent will need " +
164
+ "'homespun agent claim <code>' to get an owner.\n");
165
+ }
166
+ catch (e) {
167
+ if (e instanceof HomespunApiError) {
168
+ if (e.status === 426 && e.code === "cli_upgrade_required") {
169
+ failUpgradeRequired(e);
170
+ }
171
+ if (e.status === 429) {
172
+ fail("device authorization rate limit exceeded - try again later", "rate_limited", undefined, { hint: e.hint, retryable: true, docs_url: e.docsUrl });
173
+ }
174
+ fail(e.message, e.code, e.details, {
175
+ hint: e.hint,
176
+ retryable: e.retryable,
177
+ docs_url: e.docsUrl,
178
+ });
179
+ }
180
+ fail(e instanceof Error ? e.message : String(e), "internal");
181
+ }
182
+ }
88
183
  let result;
89
184
  try {
90
185
  result = await registerAgent({
91
- url: url.replace(/\/$/, ""),
186
+ url,
92
187
  ...(name !== undefined ? { name } : {}),
93
188
  ...(secret !== undefined && secret !== "" ? { secret } : {}),
94
189
  cliVersion: VERSION,
@@ -103,7 +198,7 @@ export async function runRegister(args) {
103
198
  failUpgradeRequired(e);
104
199
  }
105
200
  if (e.status === 429) {
106
- fail("registration rate limit exceeded try again later", "rate_limited", undefined, { hint: e.hint, retryable: e.retryable, docs_url: e.docsUrl });
201
+ fail("registration rate limit exceeded - try again later", "rate_limited", undefined, { hint: e.hint, retryable: e.retryable, docs_url: e.docsUrl });
107
202
  }
108
203
  fail(e.message, e.code, e.details, {
109
204
  hint: e.hint,
@@ -117,12 +212,13 @@ export async function runRegister(args) {
117
212
  // registered against this relay, so the only sensible follow-up is to
118
213
  // start using it. The previous behaviour (one global URL+key) is exactly
119
214
  // the single-profile case of this.
120
- const savedTo = upsertProfile(profileName, { url: url.replace(/\/$/, ""), apiKey: result.api_key }, true);
215
+ const savedTo = upsertProfile(profileName, { url, apiKey: result.api_key }, true);
121
216
  const out = {
122
217
  agent_id: result.agent_id,
123
218
  key_prefix: result.key_prefix,
124
219
  profile: profileName,
125
220
  saved_to: savedTo,
221
+ registered_via: "direct",
126
222
  };
127
223
  if (args.bools.has("print-key")) {
128
224
  out["api_key"] = result.api_key;
@@ -0,0 +1,162 @@
1
+ // Device-authorization registration (RFC 8628 style) - the browser-approval
2
+ // path behind `homespun agent register`.
3
+ //
4
+ // The CLI asks the relay for a device_code + user_code pair, prints the
5
+ // verification URL + code for the human (who can open it on ANY device),
6
+ // and polls POST /v1/device/token until the human approves, denies, or the
7
+ // codes expire. On approval the relay creates an agent OWNED by the
8
+ // approving human and returns its key exactly once.
9
+ //
10
+ // Everything human-facing goes to stderr - stdout stays reserved for the
11
+ // final JSON envelope, like every other command. fetch/sleep/print are
12
+ // injectable so the polling loop is unit-testable with a mocked relay.
13
+ import { HomespunApiError } from "@homespunapps/core";
14
+ function defaultSleep(ms) {
15
+ return new Promise((resolve) => setTimeout(resolve, ms));
16
+ }
17
+ function defaultPrint(line) {
18
+ process.stderr.write(line + "\n");
19
+ }
20
+ /** POST JSON, returning { status, body } with the body parsed best-effort. */
21
+ async function postJson(fetchImpl, url, body, cliVersion) {
22
+ const headers = {
23
+ "content-type": "application/json",
24
+ };
25
+ if (cliVersion !== undefined && cliVersion !== "") {
26
+ headers["x-homespun-cli-version"] = cliVersion;
27
+ }
28
+ let res;
29
+ try {
30
+ res = await fetchImpl(url, {
31
+ method: "POST",
32
+ headers,
33
+ body: JSON.stringify(body),
34
+ });
35
+ }
36
+ catch (e) {
37
+ const msg = e instanceof Error ? e.message : String(e);
38
+ throw new HomespunApiError(0, "fetch_error", msg);
39
+ }
40
+ const text = await res.text().catch(() => "");
41
+ let parsed = null;
42
+ if (text !== "") {
43
+ try {
44
+ parsed = JSON.parse(text);
45
+ }
46
+ catch {
47
+ parsed = null;
48
+ }
49
+ }
50
+ return { status: res.status, body: parsed };
51
+ }
52
+ /** The relay-envelope error code/message, when the body carries one. */
53
+ function envelopeError(body) {
54
+ const err = body?.error;
55
+ if (err && typeof err === "object") {
56
+ const e = err;
57
+ return {
58
+ code: typeof e.code === "string" ? e.code : "relay_error",
59
+ message: typeof e.message === "string" ? e.message : "relay error",
60
+ };
61
+ }
62
+ return null;
63
+ }
64
+ /** The RFC 8628 error string ({ error: "authorization_pending" }), if any. */
65
+ function rfcErrorCode(body) {
66
+ const err = body?.error;
67
+ return typeof err === "string" ? err : null;
68
+ }
69
+ /**
70
+ * Run the device-authorization flow end to end. Returns `supported: false`
71
+ * when the relay 404s the code request (an older relay - the caller falls
72
+ * back to plain POST /v1/register). Throws HomespunApiError on every other
73
+ * failure, including denial and expiry, with actionable codes:
74
+ *
75
+ * device_flow_denied the human clicked Deny
76
+ * device_flow_expired nobody approved within the code's lifetime
77
+ */
78
+ export async function runDeviceFlow(opts) {
79
+ const base = opts.url.replace(/\/$/, "");
80
+ const fetchImpl = opts.fetchImpl ?? fetch;
81
+ const sleep = opts.sleepImpl ?? defaultSleep;
82
+ const print = opts.print ?? defaultPrint;
83
+ // ---- 1. Request the code pair -----------------------------------------
84
+ const start = await postJson(fetchImpl, `${base}/v1/device/code`, { name: opts.name }, opts.cliVersion);
85
+ if (start.status === 404) {
86
+ // Older relay without the device flow - signal the caller to fall back.
87
+ return { supported: false };
88
+ }
89
+ if (start.status !== 200) {
90
+ const env = envelopeError(start.body);
91
+ throw new HomespunApiError(start.status, env?.code ?? "relay_error", env?.message ?? `relay returned ${start.status} for /v1/device/code`);
92
+ }
93
+ const code = start.body;
94
+ if (!code ||
95
+ typeof code.device_code !== "string" ||
96
+ typeof code.user_code !== "string" ||
97
+ typeof code.verification_uri_complete !== "string") {
98
+ throw new HomespunApiError(200, "invalid_response", "relay returned an unexpected /v1/device/code body");
99
+ }
100
+ // ---- 2. Hand the human their marching orders ---------------------------
101
+ const expiresMin = Math.max(1, Math.round((code.expires_in ?? 900) / 60));
102
+ print("");
103
+ print("To approve this agent, open:");
104
+ print("");
105
+ print(` ${code.verification_uri_complete}`);
106
+ print("");
107
+ print(`and confirm the code: ${code.user_code}`);
108
+ print("");
109
+ print("You can open the link on any device (phone or laptop) and sign in there.");
110
+ print(`Waiting for approval... (expires in ${expiresMin} min; Ctrl-C to abort)`);
111
+ // ---- 3. Poll until a terminal answer -----------------------------------
112
+ let intervalSeconds = typeof code.interval === "number" && code.interval > 0 ? code.interval : 5;
113
+ const deadline = Date.now() + (code.expires_in ?? 900) * 1000;
114
+ while (Date.now() < deadline) {
115
+ await sleep(intervalSeconds * 1000);
116
+ // Re-check after sleeping: don't fire a poll we already know is past
117
+ // the code's lifetime (the relay would just answer expired_token).
118
+ if (Date.now() >= deadline)
119
+ break;
120
+ const poll = await postJson(fetchImpl, `${base}/v1/device/token`, { device_code: code.device_code }, opts.cliVersion);
121
+ if (poll.status === 200) {
122
+ const body = poll.body;
123
+ if (typeof body?.agent_key !== "string" ||
124
+ typeof body?.agent_id !== "string") {
125
+ throw new HomespunApiError(200, "invalid_response", "relay returned an unexpected /v1/device/token body");
126
+ }
127
+ print("Approved.");
128
+ return {
129
+ supported: true,
130
+ agent_id: body.agent_id,
131
+ agent_key: body.agent_key,
132
+ name: typeof body.name === "string" ? body.name : opts.name,
133
+ };
134
+ }
135
+ const rfc = rfcErrorCode(poll.body);
136
+ if (poll.status === 400 && rfc !== null) {
137
+ switch (rfc) {
138
+ case "authorization_pending":
139
+ continue;
140
+ case "slow_down":
141
+ // RFC 8628 §3.5: add 5 seconds to the interval and keep going.
142
+ intervalSeconds += 5;
143
+ continue;
144
+ case "access_denied":
145
+ throw new HomespunApiError(400, "device_flow_denied", "the approval request was denied in the browser");
146
+ case "expired_token":
147
+ throw new HomespunApiError(400, "device_flow_expired", "the device code expired before it was approved - run 'homespun agent register' again");
148
+ default:
149
+ throw new HomespunApiError(400, rfc, `relay rejected the poll (${rfc})`);
150
+ }
151
+ }
152
+ if (poll.status === 429) {
153
+ // Transient general rate limit - back off like a slow_down and retry.
154
+ intervalSeconds += 5;
155
+ continue;
156
+ }
157
+ // 426 cli_upgrade_required and anything else: surface the envelope.
158
+ const env = envelopeError(poll.body);
159
+ throw new HomespunApiError(poll.status, env?.code ?? "relay_error", env?.message ?? `relay returned ${poll.status} for /v1/device/token`, poll.body?.error?.details);
160
+ }
161
+ throw new HomespunApiError(400, "device_flow_expired", "the device code expired before it was approved - run 'homespun agent register' again");
162
+ }
package/dist/index.js CHANGED
@@ -110,6 +110,9 @@ const BOOLEAN_FLAGS = new Set([
110
110
  "plain",
111
111
  // `homespun deploy --force` / `homespun apps ... --force`: override a compat gate.
112
112
  "force",
113
+ // `homespun agent register --no-device`: skip the browser device-authorization
114
+ // flow and register directly (unowned agent), the pre-device-flow behavior.
115
+ "no-device",
113
116
  ]);
114
117
  async function main() {
115
118
  const rawArgv = process.argv.slice(2);
package/dist/version.js CHANGED
@@ -1,11 +1,25 @@
1
1
  // Single source of truth for the CLI version string.
2
2
  //
3
3
  // - `homespun --version` prints this verbatim.
4
- // - Every HomespunClient construction passes it as `cliVersion`, which apps
5
- // as the `x-homespun-cli-version` header on every relay request drives the
4
+ // - Every HomespunClient construction passes it as `cliVersion`, which rides
5
+ // as the `x-homespun-cli-version` header on every relay request, driving the
6
6
  // relay's version-skew check (HTTP 426 `cli_upgrade_required`).
7
7
  //
8
- // Keep this in lockstep with packages/cli/package.json's `version` field;
9
- // they're consulted in different places (here for the runtime header,
10
- // package.json for npm publish + dependency resolution).
11
- export const VERSION = "0.0.29";
8
+ // The value is read at runtime from THIS package's own `package.json`
9
+ // `version` field rather than hardcoded, so it can never drift from what npm
10
+ // published. Both the built `dist/version.js` and the source `src/version.ts`
11
+ // sit exactly one directory below the package root, so `../package.json`
12
+ // resolves the same in either location.
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ function readVersion() {
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const pkgPath = resolve(here, "..", "package.json");
19
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
20
+ if (typeof pkg.version !== "string" || pkg.version.length === 0) {
21
+ throw new Error(`homespun CLI: missing version in ${pkgPath}`);
22
+ }
23
+ return pkg.version;
24
+ }
25
+ export const VERSION = readVersion();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homespunapps/cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Command-line client for the Homespun relay: create apps, inspect state, send and watch events.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,14 +11,9 @@
11
11
  "relay",
12
12
  "human-in-the-loop"
13
13
  ],
14
- "homepage": "https://github.com/aerolalit/homespun#readme",
15
- "repository": {
16
- "type": "git",
17
- "url": "git+https://github.com/aerolalit/homespun.git",
18
- "directory": "packages/cli"
19
- },
14
+ "homepage": "https://homespun.dev",
20
15
  "bugs": {
21
- "url": "https://github.com/aerolalit/homespun/issues"
16
+ "url": "mailto:support@homespun.dev"
22
17
  },
23
18
  "engines": {
24
19
  "node": ">=20"