@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
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// `homespun config <verb>` — inspect and manage the multi-profile CLI config.
|
|
2
|
+
//
|
|
3
|
+
// show describe the resolved (url, api_key) the CLI would use
|
|
4
|
+
// list list saved profiles (names + URLs + current marker)
|
|
5
|
+
// use <name> switch the active profile
|
|
6
|
+
// add <name> manually add a profile (for keys obtained out of band)
|
|
7
|
+
// rm <name> delete a profile
|
|
8
|
+
//
|
|
9
|
+
// All four mutating verbs operate on the multi-profile store at
|
|
10
|
+
// $XDG_CONFIG_HOME/homespun/config.json. See store.ts for the layout. They make
|
|
11
|
+
// NO network calls — purely local config management.
|
|
12
|
+
import { assertKnownFlags } from "../argv.js";
|
|
13
|
+
import { describeConfig } from "../config.js";
|
|
14
|
+
import { isValidProfileName, readStore, removeProfile, setCurrentProfile, storePath, upsertProfile, } from "../store.js";
|
|
15
|
+
import { printJson, fail } from "../output.js";
|
|
16
|
+
const SHOW_FLAGS = [];
|
|
17
|
+
const SHOW_BOOLS = [];
|
|
18
|
+
const LIST_FLAGS = [];
|
|
19
|
+
const LIST_BOOLS = [];
|
|
20
|
+
const USE_FLAGS = [];
|
|
21
|
+
const USE_BOOLS = [];
|
|
22
|
+
const ADD_FLAGS = ["api-key"];
|
|
23
|
+
const ADD_BOOLS = [];
|
|
24
|
+
const RM_FLAGS = [];
|
|
25
|
+
const RM_BOOLS = [];
|
|
26
|
+
export const configHelp = `homespun config — show and manage the CLI config (multi-profile)
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
homespun config show Show the resolved relay config
|
|
30
|
+
homespun config list List saved profiles
|
|
31
|
+
homespun config use <profile> Switch the active profile
|
|
32
|
+
homespun config add <profile> --url <u> --api-key <k>
|
|
33
|
+
Add a profile manually
|
|
34
|
+
homespun config rm <profile> Delete a profile
|
|
35
|
+
|
|
36
|
+
A profile is one (url, api_key) pair under a short name (dev, staging, prod).
|
|
37
|
+
Switch via 'homespun config use', '--profile <name>', or the HOMESPUN_PROFILE env var.
|
|
38
|
+
The active profile's (url, api_key) is what every other command sees unless
|
|
39
|
+
overridden by --url / --api-key or HOMESPUN_URL / HOMESPUN_API_KEY.
|
|
40
|
+
|
|
41
|
+
Run \`homespun config <verb> --help\` for verb-specific help. The full API key is
|
|
42
|
+
never printed; only a short masked prefix.
|
|
43
|
+
|
|
44
|
+
The config file lives at \${XDG_CONFIG_HOME:-~/.config}/homespun/config.json
|
|
45
|
+
(mode 0600).`;
|
|
46
|
+
const showHelp = `homespun config show — show the resolved relay config
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
homespun config show [options]
|
|
50
|
+
|
|
51
|
+
Reports the (url, api_key) the CLI would use right now, and where each value
|
|
52
|
+
came from (flag / env / profile / none). Purely inspects flags + env + the
|
|
53
|
+
saved config file; makes NO network call.
|
|
54
|
+
|
|
55
|
+
The API key is never printed in full — only a short masked prefix.
|
|
56
|
+
|
|
57
|
+
Options:
|
|
58
|
+
--url <url> Relay base URL (overrides HOMESPUN_URL) — affects the report.
|
|
59
|
+
--api-key <key> Agent API key (overrides HOMESPUN_API_KEY) — affects the report.
|
|
60
|
+
--profile <name> Profile to resolve against — affects the report.
|
|
61
|
+
-h, --help Show this help.
|
|
62
|
+
|
|
63
|
+
Output (stdout, JSON):
|
|
64
|
+
{
|
|
65
|
+
url, url_source, flag | env | profile | none
|
|
66
|
+
key_prefix, key_source, flag | env | profile | none
|
|
67
|
+
profile, profile_source, active profile name + how it was chosen
|
|
68
|
+
config_path
|
|
69
|
+
}`;
|
|
70
|
+
const listHelp = `homespun config list — list saved profiles
|
|
71
|
+
|
|
72
|
+
Usage:
|
|
73
|
+
homespun config list [options]
|
|
74
|
+
|
|
75
|
+
Prints every profile in the local config file, with its URL and a masked
|
|
76
|
+
key prefix. The active profile carries 'current: true'.
|
|
77
|
+
|
|
78
|
+
Options:
|
|
79
|
+
-h, --help Show this help.
|
|
80
|
+
|
|
81
|
+
Output (stdout, JSON):
|
|
82
|
+
{
|
|
83
|
+
current: <name|null>,
|
|
84
|
+
profiles: [ { name, url, key_prefix, current }, … ],
|
|
85
|
+
config_path
|
|
86
|
+
}`;
|
|
87
|
+
const useHelp = `homespun config use <profile> — switch the active profile
|
|
88
|
+
|
|
89
|
+
Usage:
|
|
90
|
+
homespun config use <profile>
|
|
91
|
+
|
|
92
|
+
Sets 'current_profile' in the config file. The named profile must exist
|
|
93
|
+
(create it first with 'homespun agent register --profile <name>' or
|
|
94
|
+
'homespun config add <name>').
|
|
95
|
+
|
|
96
|
+
Options:
|
|
97
|
+
-h, --help Show this help.
|
|
98
|
+
|
|
99
|
+
Output (stdout, JSON):
|
|
100
|
+
{ profile, saved_to }`;
|
|
101
|
+
const addHelp = `homespun config add <profile> — add a profile manually
|
|
102
|
+
|
|
103
|
+
Usage:
|
|
104
|
+
homespun config add <profile> --url <url> --api-key <api-key>
|
|
105
|
+
|
|
106
|
+
Saves a (url, api_key) pair under <profile> without contacting the relay.
|
|
107
|
+
Use this when an operator handed you an API key out of band (e.g. a closed-
|
|
108
|
+
registration relay) — for self-register and secret-mode relays, prefer
|
|
109
|
+
'homespun agent register --profile <name>'.
|
|
110
|
+
|
|
111
|
+
If <profile> already exists, the existing values are overwritten.
|
|
112
|
+
|
|
113
|
+
Options:
|
|
114
|
+
--url <url> Relay base URL. REQUIRED.
|
|
115
|
+
--api-key <key> Agent API key. REQUIRED.
|
|
116
|
+
-h, --help Show this help.
|
|
117
|
+
|
|
118
|
+
Output (stdout, JSON):
|
|
119
|
+
{ profile, saved_to }
|
|
120
|
+
|
|
121
|
+
Does NOT change 'current_profile' unless this is the first profile being
|
|
122
|
+
added. Use 'homespun config use' afterwards to switch.`;
|
|
123
|
+
const rmHelp = `homespun config rm <profile> — delete a profile
|
|
124
|
+
|
|
125
|
+
Usage:
|
|
126
|
+
homespun config rm <profile>
|
|
127
|
+
|
|
128
|
+
Removes the named profile from the config file. If it was the active profile,
|
|
129
|
+
'current_profile' is cleared (the next command falls back to env / default
|
|
130
|
+
URL until another profile is selected via --profile or 'homespun config use').
|
|
131
|
+
|
|
132
|
+
Options:
|
|
133
|
+
-h, --help Show this help.
|
|
134
|
+
|
|
135
|
+
Output (stdout, JSON):
|
|
136
|
+
{ profile, was_current, path }`;
|
|
137
|
+
async function runConfigShow(args) {
|
|
138
|
+
assertKnownFlags(args, SHOW_FLAGS, SHOW_BOOLS, "homespun config show");
|
|
139
|
+
printJson(describeConfig(args));
|
|
140
|
+
}
|
|
141
|
+
function maskKey(key) {
|
|
142
|
+
if (!key)
|
|
143
|
+
return null;
|
|
144
|
+
if (key.startsWith("hs_") && key.length >= 9)
|
|
145
|
+
return key.slice(0, 9) + "…";
|
|
146
|
+
return key.slice(0, 8) + "…";
|
|
147
|
+
}
|
|
148
|
+
async function runConfigList(args) {
|
|
149
|
+
assertKnownFlags(args, LIST_FLAGS, LIST_BOOLS, "homespun config list");
|
|
150
|
+
const store = readStore();
|
|
151
|
+
const profiles = Object.entries(store.profiles)
|
|
152
|
+
.map(([name, p]) => ({
|
|
153
|
+
name,
|
|
154
|
+
url: p.url ?? null,
|
|
155
|
+
key_prefix: maskKey(p.apiKey),
|
|
156
|
+
current: name === store.currentProfile,
|
|
157
|
+
}))
|
|
158
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
159
|
+
printJson({
|
|
160
|
+
current: store.currentProfile ?? null,
|
|
161
|
+
profiles,
|
|
162
|
+
config_path: storePath(),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
async function runConfigUse(args) {
|
|
166
|
+
assertKnownFlags(args, USE_FLAGS, USE_BOOLS, "homespun config use");
|
|
167
|
+
const name = args.positionals[1];
|
|
168
|
+
if (!name) {
|
|
169
|
+
fail("missing profile name — usage: homespun config use <profile>", "invalid_args");
|
|
170
|
+
}
|
|
171
|
+
let savedTo;
|
|
172
|
+
try {
|
|
173
|
+
savedTo = setCurrentProfile(name);
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
fail(e instanceof Error ? e.message : String(e), "config_error");
|
|
177
|
+
}
|
|
178
|
+
printJson({ profile: name, saved_to: savedTo });
|
|
179
|
+
}
|
|
180
|
+
async function runConfigAdd(args) {
|
|
181
|
+
assertKnownFlags(args, ADD_FLAGS, ADD_BOOLS, "homespun config add");
|
|
182
|
+
const name = args.positionals[1];
|
|
183
|
+
if (!name) {
|
|
184
|
+
fail("missing profile name — usage: homespun config add <profile> --url <url> --api-key <key>", "invalid_args");
|
|
185
|
+
}
|
|
186
|
+
if (!isValidProfileName(name)) {
|
|
187
|
+
fail(`invalid profile name '${name}' — letters, digits, _ and -, up to 32 chars`, "invalid_args");
|
|
188
|
+
}
|
|
189
|
+
const url = args.flags.get("url");
|
|
190
|
+
const apiKey = args.flags.get("api-key");
|
|
191
|
+
if (!url) {
|
|
192
|
+
fail("--url is required — usage: homespun config add <profile> --url <url> --api-key <key>", "invalid_args");
|
|
193
|
+
}
|
|
194
|
+
if (!apiKey) {
|
|
195
|
+
fail("--api-key is required — usage: homespun config add <profile> --url <url> --api-key <key>", "invalid_args");
|
|
196
|
+
}
|
|
197
|
+
// setCurrent=false: adding a profile shouldn't silently switch the user
|
|
198
|
+
// off whatever they were on. They use `homespun config use` after to flip.
|
|
199
|
+
// EXCEPT: if there's no current profile yet (first add), upsertProfile
|
|
200
|
+
// sets it automatically — that's the correct fresh-install behaviour.
|
|
201
|
+
const savedTo = upsertProfile(name, { url: url.replace(/\/$/, ""), apiKey }, false);
|
|
202
|
+
printJson({ profile: name, saved_to: savedTo });
|
|
203
|
+
}
|
|
204
|
+
async function runConfigRm(args) {
|
|
205
|
+
assertKnownFlags(args, RM_FLAGS, RM_BOOLS, "homespun config rm");
|
|
206
|
+
const name = args.positionals[1];
|
|
207
|
+
if (!name) {
|
|
208
|
+
fail("missing profile name — usage: homespun config rm <profile>", "invalid_args");
|
|
209
|
+
}
|
|
210
|
+
let result;
|
|
211
|
+
try {
|
|
212
|
+
result = removeProfile(name);
|
|
213
|
+
}
|
|
214
|
+
catch (e) {
|
|
215
|
+
fail(e instanceof Error ? e.message : String(e), "config_error");
|
|
216
|
+
}
|
|
217
|
+
printJson({
|
|
218
|
+
profile: name,
|
|
219
|
+
was_current: result.was_current,
|
|
220
|
+
path: result.path,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
export async function runConfig(args) {
|
|
224
|
+
const verb = args.positionals[0];
|
|
225
|
+
// Per-verb --help: 'homespun config show --help' etc. Caught before dispatch
|
|
226
|
+
// so each runner doesn't need to repeat the check.
|
|
227
|
+
if (args.bools.has("help") && verb !== undefined) {
|
|
228
|
+
const helps = {
|
|
229
|
+
show: showHelp,
|
|
230
|
+
list: listHelp,
|
|
231
|
+
use: useHelp,
|
|
232
|
+
add: addHelp,
|
|
233
|
+
rm: rmHelp,
|
|
234
|
+
};
|
|
235
|
+
if (helps[verb] !== undefined) {
|
|
236
|
+
process.stdout.write(helps[verb] + "\n");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
switch (verb) {
|
|
241
|
+
case "show":
|
|
242
|
+
await runConfigShow(args);
|
|
243
|
+
break;
|
|
244
|
+
case "list":
|
|
245
|
+
await runConfigList(args);
|
|
246
|
+
break;
|
|
247
|
+
case "use":
|
|
248
|
+
await runConfigUse(args);
|
|
249
|
+
break;
|
|
250
|
+
case "add":
|
|
251
|
+
await runConfigAdd(args);
|
|
252
|
+
break;
|
|
253
|
+
case "rm":
|
|
254
|
+
await runConfigRm(args);
|
|
255
|
+
break;
|
|
256
|
+
case undefined:
|
|
257
|
+
fail("missing verb — usage: homespun config <show|list|use|add|rm> (run 'homespun config --help')", "invalid_args");
|
|
258
|
+
break;
|
|
259
|
+
default:
|
|
260
|
+
fail(`unknown config verb '${verb}' — expected show|list|use|add|rm (run 'homespun config --help')`, "invalid_args");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// `homespun data` — collection row CRUD for a v2 app (spec-cli §3.3). Mirrors
|
|
2
|
+
// `homespun records` (v1 app collections) but against `/v1/apps/:id/collections`
|
|
3
|
+
// and with a dedicated per-row GET (no client-side scan — spec-cli §8 ruling
|
|
4
|
+
// 3 confirms the relay route is real).
|
|
5
|
+
import { assertKnownFlags } from "../argv.js";
|
|
6
|
+
import { makeClient } from "../config.js";
|
|
7
|
+
import { fail, failFromError, printJson } from "../output.js";
|
|
8
|
+
import { resolveJson } from "../input.js";
|
|
9
|
+
import { resolveAppId } from "../resolve-app.js";
|
|
10
|
+
export const dataHelp = `homespun data — collection row CRUD for a v2 app
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
homespun data <app> <collection> list [--since <cursor>] [--limit <n>]
|
|
14
|
+
homespun data <app> <collection> get <key>
|
|
15
|
+
homespun data <app> <collection> upsert --data <path|json> [--key <key>]
|
|
16
|
+
homespun data <app> <collection> update <key> --data <path|json> [--if-match <version>]
|
|
17
|
+
homespun data <app> <collection> delete <key> [--if-match <version>] [--yes]
|
|
18
|
+
|
|
19
|
+
<app> accepts either the app_id or its slug. upsert is the ONLY create-shaped
|
|
20
|
+
verb (spec-cli §8 ruling 4): omit --key to add a new row (server-generates
|
|
21
|
+
the key); pass --key to ensure a row exists at that key (returns the
|
|
22
|
+
existing row with deduped:true on a collision, never errors).
|
|
23
|
+
|
|
24
|
+
Output (stdout, single JSON). Errors on stderr:
|
|
25
|
+
{"error":{"code","message"}} with non-zero exit.`;
|
|
26
|
+
export async function runData(args) {
|
|
27
|
+
const appArg = args.positionals[0];
|
|
28
|
+
const collection = args.positionals[1];
|
|
29
|
+
const verb = args.positionals[2];
|
|
30
|
+
if ((appArg === undefined || verb === undefined) && args.bools.has("help")) {
|
|
31
|
+
process.stdout.write(dataHelp + "\n");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (!appArg || !collection || !verb) {
|
|
35
|
+
fail("usage: homespun data <app> <collection> <list|get|upsert|update|delete>", "invalid_args");
|
|
36
|
+
}
|
|
37
|
+
const sub = {
|
|
38
|
+
positionals: args.positionals.slice(3),
|
|
39
|
+
flags: args.flags,
|
|
40
|
+
bools: args.bools,
|
|
41
|
+
...(args.danglingValueFlags !== undefined
|
|
42
|
+
? { danglingValueFlags: args.danglingValueFlags }
|
|
43
|
+
: {}),
|
|
44
|
+
};
|
|
45
|
+
switch (verb) {
|
|
46
|
+
case "list":
|
|
47
|
+
return runList(appArg, collection, sub);
|
|
48
|
+
case "get":
|
|
49
|
+
return runGet(appArg, collection, sub);
|
|
50
|
+
case "upsert":
|
|
51
|
+
return runUpsert(appArg, collection, sub);
|
|
52
|
+
case "update":
|
|
53
|
+
return runUpdate(appArg, collection, sub);
|
|
54
|
+
case "delete":
|
|
55
|
+
return runDelete(appArg, collection, sub);
|
|
56
|
+
default:
|
|
57
|
+
fail(`unknown verb '${verb}' — homespun data <app> <collection> <list|get|upsert|update|delete>`, "invalid_args");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function parseIntFlag(args, name, defaultValue, bounds = {}) {
|
|
61
|
+
const raw = args.flags.get(name);
|
|
62
|
+
if (raw === undefined)
|
|
63
|
+
return defaultValue;
|
|
64
|
+
const n = Number(raw);
|
|
65
|
+
if (!Number.isInteger(n))
|
|
66
|
+
fail(`--${name} must be an integer`, "invalid_args");
|
|
67
|
+
if (bounds.min !== undefined && n < bounds.min) {
|
|
68
|
+
fail(`--${name} must be >= ${bounds.min}`, "invalid_args");
|
|
69
|
+
}
|
|
70
|
+
if (bounds.max !== undefined && n > bounds.max) {
|
|
71
|
+
fail(`--${name} must be <= ${bounds.max}`, "invalid_args");
|
|
72
|
+
}
|
|
73
|
+
return n;
|
|
74
|
+
}
|
|
75
|
+
async function runList(appArg, collection, args) {
|
|
76
|
+
assertKnownFlags(args, ["since", "limit", "url", "api-key"], ["help"], "homespun data list");
|
|
77
|
+
const since = args.flags.get("since");
|
|
78
|
+
const limit = parseIntFlag(args, "limit", undefined, { min: 1, max: 1000 });
|
|
79
|
+
const client = makeClient(args);
|
|
80
|
+
const appId = await resolveAppId(client, appArg);
|
|
81
|
+
try {
|
|
82
|
+
printJson(await client.listAppRows(appId, collection, {
|
|
83
|
+
since,
|
|
84
|
+
...(limit !== undefined ? { limit } : {}),
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
failFromError(e);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function runGet(appArg, collection, args) {
|
|
92
|
+
assertKnownFlags(args, ["url", "api-key"], ["help"], "homespun data get");
|
|
93
|
+
const key = args.positionals[0];
|
|
94
|
+
if (!key) {
|
|
95
|
+
fail("usage: homespun data <app> <collection> get <key>", "invalid_args");
|
|
96
|
+
}
|
|
97
|
+
const client = makeClient(args);
|
|
98
|
+
const appId = await resolveAppId(client, appArg);
|
|
99
|
+
try {
|
|
100
|
+
printJson(await client.getAppRow(appId, collection, key));
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
failFromError(e);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function runUpsert(appArg, collection, args) {
|
|
107
|
+
assertKnownFlags(args, ["data", "key", "url", "api-key"], ["help"], "homespun data upsert");
|
|
108
|
+
const dataRaw = args.flags.get("data");
|
|
109
|
+
if (dataRaw === undefined) {
|
|
110
|
+
fail("--data is required (path to JSON file, or inline JSON)", "invalid_args");
|
|
111
|
+
}
|
|
112
|
+
const data = resolveJson(dataRaw, "--data");
|
|
113
|
+
const key = args.flags.get("key");
|
|
114
|
+
const client = makeClient(args);
|
|
115
|
+
const appId = await resolveAppId(client, appArg);
|
|
116
|
+
try {
|
|
117
|
+
const body = { data };
|
|
118
|
+
if (key !== undefined)
|
|
119
|
+
body.key = key;
|
|
120
|
+
printJson(await client.upsertAppRow(appId, collection, body));
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
failFromError(e);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function runUpdate(appArg, collection, args) {
|
|
127
|
+
assertKnownFlags(args, ["data", "if-match", "url", "api-key"], ["help"], "homespun data update");
|
|
128
|
+
const key = args.positionals[0];
|
|
129
|
+
if (!key) {
|
|
130
|
+
fail("usage: homespun data <app> <collection> update <key> --data <path|json>", "invalid_args");
|
|
131
|
+
}
|
|
132
|
+
const dataRaw = args.flags.get("data");
|
|
133
|
+
if (dataRaw === undefined) {
|
|
134
|
+
fail("--data is required (path to JSON file, or inline JSON)", "invalid_args");
|
|
135
|
+
}
|
|
136
|
+
const data = resolveJson(dataRaw, "--data");
|
|
137
|
+
const ifMatch = parseIntFlag(args, "if-match", undefined, { min: 0 });
|
|
138
|
+
const client = makeClient(args);
|
|
139
|
+
const appId = await resolveAppId(client, appArg);
|
|
140
|
+
try {
|
|
141
|
+
const body = { data };
|
|
142
|
+
if (ifMatch !== undefined)
|
|
143
|
+
body.if_match = ifMatch;
|
|
144
|
+
printJson(await client.updateAppRow(appId, collection, key, body));
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
failFromError(e);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function runDelete(appArg, collection, args) {
|
|
151
|
+
assertKnownFlags(args, ["if-match", "url", "api-key"], ["yes", "help"], "homespun data delete");
|
|
152
|
+
const key = args.positionals[0];
|
|
153
|
+
if (!key) {
|
|
154
|
+
fail("usage: homespun data <app> <collection> delete <key>", "invalid_args");
|
|
155
|
+
}
|
|
156
|
+
const ifMatch = parseIntFlag(args, "if-match", undefined, { min: 0 });
|
|
157
|
+
const client = makeClient(args);
|
|
158
|
+
const appId = await resolveAppId(client, appArg);
|
|
159
|
+
try {
|
|
160
|
+
await client.deleteAppRow(appId, collection, key, {
|
|
161
|
+
...(ifMatch !== undefined ? { ifMatch } : {}),
|
|
162
|
+
});
|
|
163
|
+
printJson({ deleted: true, key });
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
failFromError(e);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// `homespun deploy` — create or redeploy an App (spec-cli §3.1). This is the
|
|
2
|
+
// create->redeploy loop the v2 vision names: no `--app` creates a new App;
|
|
3
|
+
// `--app <id>` redeploys an existing one (compat-gated unless --force).
|
|
4
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { makeClient } from "../config.js";
|
|
7
|
+
import { assertKnownFlags } from "../argv.js";
|
|
8
|
+
import { fail, failFromError, printJson } from "../output.js";
|
|
9
|
+
import { resolveJson } from "../input.js";
|
|
10
|
+
import { resolveAppId } from "../resolve-app.js";
|
|
11
|
+
const KNOWN_FLAGS = ["app", "manifest", "slug", "visibility"];
|
|
12
|
+
const KNOWN_BOOLS = ["force"];
|
|
13
|
+
export const deployHelp = `homespun deploy — create or redeploy an app
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
homespun deploy <dir|file> [--app <id>] [--manifest <path|json>]
|
|
17
|
+
[--slug <slug>] [--visibility private|link|public] [--force]
|
|
18
|
+
|
|
19
|
+
Packaging (one canonical shape, one escape hatch):
|
|
20
|
+
Directory (canonical): homespun deploy ./my-app
|
|
21
|
+
Reads ./my-app/index.html and ./my-app/manifest.json — fixed filenames,
|
|
22
|
+
no discovery heuristics. Both files are required.
|
|
23
|
+
Single file (escape hatch): homespun deploy ./index.html --manifest ./manifest.json
|
|
24
|
+
--manifest accepts a file path OR inline JSON.
|
|
25
|
+
|
|
26
|
+
Create vs. redeploy — decided by --app's presence, not two verbs:
|
|
27
|
+
(no --app) Create (POST /v1/apps). New apps default to private
|
|
28
|
+
(owner plus invited members, sign-in gated). --slug is
|
|
29
|
+
accepted with private or public visibility, including the
|
|
30
|
+
default; an explicit --visibility link always gets a
|
|
31
|
+
server-generated slug and rejects --slug.
|
|
32
|
+
--app <id> Redeploy (POST /v1/apps/:id/versions). --slug/--visibility
|
|
33
|
+
are rejected here (slug is immutable; change visibility via
|
|
34
|
+
'homespun apps update'). --force overrides the compat gate.
|
|
35
|
+
|
|
36
|
+
Output: { app_id, slug, url, version, visibility, created, compat?, breaks? }
|
|
37
|
+
Errors on stderr: {"error":{"code","message"}} with non-zero exit.`;
|
|
38
|
+
function readBundle(source, manifestFlag) {
|
|
39
|
+
if (!existsSync(source)) {
|
|
40
|
+
fail(`no such file or directory: ${source}`, "invalid_args");
|
|
41
|
+
}
|
|
42
|
+
const st = statSync(source);
|
|
43
|
+
if (st.isDirectory()) {
|
|
44
|
+
if (manifestFlag !== undefined) {
|
|
45
|
+
fail("--manifest is only for the single-file escape hatch — a directory deploy reads <dir>/manifest.json", "invalid_args");
|
|
46
|
+
}
|
|
47
|
+
const htmlPath = join(source, "index.html");
|
|
48
|
+
const manifestPath = join(source, "manifest.json");
|
|
49
|
+
const missing = [];
|
|
50
|
+
if (!existsSync(htmlPath))
|
|
51
|
+
missing.push("index.html");
|
|
52
|
+
if (!existsSync(manifestPath))
|
|
53
|
+
missing.push("manifest.json");
|
|
54
|
+
if (missing.length > 0) {
|
|
55
|
+
fail(`directory deploy is missing required file(s): ${missing.join(", ")}`, "invalid_args");
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
html: readFileSync(htmlPath, "utf8"),
|
|
59
|
+
manifest: JSON.parse(readFileSync(manifestPath, "utf8")),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Single-file escape hatch.
|
|
63
|
+
if (manifestFlag === undefined) {
|
|
64
|
+
fail("single-file deploy requires --manifest <path|json>", "invalid_args");
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
html: readFileSync(source, "utf8"),
|
|
68
|
+
manifest: resolveJson(manifestFlag, "--manifest"),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export async function runDeploy(args) {
|
|
72
|
+
assertKnownFlags(args, KNOWN_FLAGS, KNOWN_BOOLS, "homespun deploy");
|
|
73
|
+
const source = args.positionals[0];
|
|
74
|
+
if (!source) {
|
|
75
|
+
fail("usage: homespun deploy <dir|file> [--app <id>] ...", "invalid_args");
|
|
76
|
+
}
|
|
77
|
+
const appId = args.flags.get("app");
|
|
78
|
+
const slug = args.flags.get("slug");
|
|
79
|
+
const visibility = args.flags.get("visibility");
|
|
80
|
+
if (visibility !== undefined &&
|
|
81
|
+
!["private", "link", "public"].includes(visibility)) {
|
|
82
|
+
fail("--visibility must be private|link|public", "invalid_args");
|
|
83
|
+
}
|
|
84
|
+
const force = args.bools.has("force");
|
|
85
|
+
const bundle = readBundle(source, args.flags.get("manifest"));
|
|
86
|
+
const client = makeClient(args);
|
|
87
|
+
if (appId === undefined) {
|
|
88
|
+
// Create. Client-side mirror of the relay's slug_not_allowed_for_link —
|
|
89
|
+
// fail fast rather than round-trip a request that will 400 (spec-cli §3.1).
|
|
90
|
+
if (slug !== undefined && visibility === "link") {
|
|
91
|
+
fail("a caller-supplied --slug is not allowed with visibility 'link' (link slugs are always server-generated); drop --visibility link, or omit --slug", "invalid_args");
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const out = await client.deployApp({
|
|
95
|
+
html: bundle.html,
|
|
96
|
+
manifest: bundle.manifest,
|
|
97
|
+
visibility,
|
|
98
|
+
slug,
|
|
99
|
+
});
|
|
100
|
+
printJson(out);
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
failFromError(e);
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Redeploy. slug/visibility are immutable here.
|
|
108
|
+
if (slug !== undefined) {
|
|
109
|
+
fail("--slug cannot be changed on redeploy (slug is immutable) — omit --app to create a new app instead", "invalid_args");
|
|
110
|
+
}
|
|
111
|
+
if (visibility !== undefined) {
|
|
112
|
+
fail("--visibility cannot be changed on redeploy — use 'homespun apps update --visibility' instead", "invalid_args");
|
|
113
|
+
}
|
|
114
|
+
const id = await resolveAppId(client, appId);
|
|
115
|
+
try {
|
|
116
|
+
const redeployed = await client.redeployApp(id, {
|
|
117
|
+
html: bundle.html,
|
|
118
|
+
manifest: bundle.manifest,
|
|
119
|
+
force,
|
|
120
|
+
});
|
|
121
|
+
const app = await client.getApp(id);
|
|
122
|
+
printJson({
|
|
123
|
+
app_id: redeployed.app_id,
|
|
124
|
+
slug: app.slug,
|
|
125
|
+
url: app.url,
|
|
126
|
+
version: redeployed.version,
|
|
127
|
+
visibility: app.visibility,
|
|
128
|
+
created: false,
|
|
129
|
+
compat: redeployed.compat,
|
|
130
|
+
...(redeployed.breaks ? { breaks: redeployed.breaks } : {}),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch (e) {
|
|
134
|
+
failFromError(e);
|
|
135
|
+
}
|
|
136
|
+
}
|