@homespunapps/cli 1.6.44 → 1.6.45
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/argv.js +6 -0
- package/dist/commands/apps.js +15 -2
- package/dist/commands/connection.js +206 -0
- package/dist/commands/credential.js +220 -0
- package/dist/commands/deploy.js +175 -6
- package/dist/help-catalog.js +295 -5
- package/dist/index.js +8 -0
- package/dist/output.js +16 -1
- package/package.json +2 -2
package/dist/argv.js
CHANGED
|
@@ -65,6 +65,12 @@ export const BOOLEAN_FLAGS = new Set([
|
|
|
65
65
|
"attest-example-only",
|
|
66
66
|
// `homespun review respond --clear`: clear a publisher response (sends null).
|
|
67
67
|
"clear",
|
|
68
|
+
// `homespun credentials mint --no-expiry`: the explicit opt-in to NO EXPIRY
|
|
69
|
+
// (ttl_seconds: null), mutually exclusive with --ttl.
|
|
70
|
+
"no-expiry",
|
|
71
|
+
// `homespun credentials mint --members`: opt the credential into the app's
|
|
72
|
+
// member directory in its boot/hello payloads.
|
|
73
|
+
"members",
|
|
68
74
|
]);
|
|
69
75
|
/**
|
|
70
76
|
* Parse argv tokens. `booleanFlags` lists flags that never consume a value
|
package/dist/commands/apps.js
CHANGED
|
@@ -63,8 +63,8 @@ async function runList(args) {
|
|
|
63
63
|
assertKnownFlags(args, ...specFor("apps", "list"));
|
|
64
64
|
const status = args.flags.get("status");
|
|
65
65
|
if (status !== undefined &&
|
|
66
|
-
!["active", "dormant", "archived", "all"].includes(status)) {
|
|
67
|
-
fail("--status must be active|dormant|archived|all", "invalid_args");
|
|
66
|
+
!["active", "dormant", "archived", "suspended", "all"].includes(status)) {
|
|
67
|
+
fail("--status must be active|dormant|archived|suspended|all", "invalid_args");
|
|
68
68
|
}
|
|
69
69
|
const limitRaw = args.flags.get("limit");
|
|
70
70
|
const limit = limitRaw !== undefined ? Number(limitRaw) : undefined;
|
|
@@ -294,6 +294,16 @@ async function runWatch(args) {
|
|
|
294
294
|
printJsonLine({ type: "_dormant" });
|
|
295
295
|
finish(0);
|
|
296
296
|
};
|
|
297
|
+
// Operator takedown (issue #1041): the WS path gets an explicit
|
|
298
|
+
// `_suspended` frame, same as `_dormant`. There is no long-poll-side
|
|
299
|
+
// equivalent by DESIGN: see `isDormantConflict`'s comment. A suspended
|
|
300
|
+
// app's long-poll GET throws the "gone" 410, not a 409 conflict, which is
|
|
301
|
+
// exactly how `archived` already behaves there (no special case), so
|
|
302
|
+
// `runLongPoll` falls through to `failFromError` unchanged.
|
|
303
|
+
const emitSuspended = () => {
|
|
304
|
+
printJsonLine({ type: "_suspended" });
|
|
305
|
+
finish(0);
|
|
306
|
+
};
|
|
297
307
|
// Long-poll fallback loop (spec-cli §5) — GET /v1/apps/:id/feed?wait=25.
|
|
298
308
|
// Uses the SAME printFeedEntryLine as the WS entry handler below, so a
|
|
299
309
|
// caller piping `homespun apps watch` output can never tell which transport
|
|
@@ -339,6 +349,9 @@ async function runWatch(args) {
|
|
|
339
349
|
onDormant: () => {
|
|
340
350
|
emitDormant();
|
|
341
351
|
},
|
|
352
|
+
onSuspended: () => {
|
|
353
|
+
emitSuspended();
|
|
354
|
+
},
|
|
342
355
|
onResync: () => {
|
|
343
356
|
printJsonLine({ type: "resync" });
|
|
344
357
|
},
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// `homespun connections` (#1363) webhook-connection management for a v2 app:
|
|
2
|
+
// create a static or oauth2 connection, list an app's connections (metadata
|
|
3
|
+
// plus a fingerprint, never a secret), delete one, and print the browser URL
|
|
4
|
+
// that completes an oauth2 connection's owner consent. Every verb targets an
|
|
5
|
+
// app via a required `--app <idOrSlug>` flag, resolved the same way
|
|
6
|
+
// `homespun grants`/`homespun members`/`homespun data` do (resolveAppId).
|
|
7
|
+
//
|
|
8
|
+
// Auth on the relay side is owner-or-owning-agent; this CLI always
|
|
9
|
+
// authenticates as the owning agent. There is no update verb: change a
|
|
10
|
+
// connection by deleting and recreating it (matching the HTTP API).
|
|
11
|
+
//
|
|
12
|
+
// OAuth2 consent is inherently a human-in-a-browser step: the relay refuses
|
|
13
|
+
// an agent-key caller at `/connections/:name/authorize`. `authorize-url`
|
|
14
|
+
// therefore never makes a network call: it builds the URL locally and hands
|
|
15
|
+
// it back so you can pass it to the signed-in app owner to open.
|
|
16
|
+
import { assertKnownFlags } from "../argv.js";
|
|
17
|
+
import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
|
|
18
|
+
import { makeClient } from "../config.js";
|
|
19
|
+
import { fail, failFromError, printJson } from "../output.js";
|
|
20
|
+
import { resolveAppId } from "../resolve-app.js";
|
|
21
|
+
export async function runConnection(args) {
|
|
22
|
+
const verb = args.positionals[0];
|
|
23
|
+
if ((verb === undefined || verb === "help") && args.bools.has("help")) {
|
|
24
|
+
process.stdout.write(renderNounHelp(nounSpec("connections")) + "\n");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (verb === undefined) {
|
|
28
|
+
fail("missing verb: homespun connections <create|list|delete|authorize-url>", "invalid_args");
|
|
29
|
+
}
|
|
30
|
+
const sub = {
|
|
31
|
+
positionals: args.positionals.slice(1),
|
|
32
|
+
flags: args.flags,
|
|
33
|
+
bools: args.bools,
|
|
34
|
+
...(args.danglingValueFlags !== undefined
|
|
35
|
+
? { danglingValueFlags: args.danglingValueFlags }
|
|
36
|
+
: {}),
|
|
37
|
+
};
|
|
38
|
+
switch (verb) {
|
|
39
|
+
case "create":
|
|
40
|
+
return runCreate(sub);
|
|
41
|
+
case "list":
|
|
42
|
+
return runList(sub);
|
|
43
|
+
case "delete":
|
|
44
|
+
return runDelete(sub);
|
|
45
|
+
case "authorize-url":
|
|
46
|
+
return runAuthorizeUrl(sub);
|
|
47
|
+
default:
|
|
48
|
+
fail(`unknown verb '${verb}' (homespun connections <create|list|delete|authorize-url>)`, "invalid_args");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Parse a "key=value" list, one per --param flag repetition is not supported
|
|
52
|
+
// by this parser (each flag is single-valued), so extra params travel as a
|
|
53
|
+
// JSON object flag, mirroring `homespun grants mint --pin-where <json>`.
|
|
54
|
+
function parseJsonObjectFlag(raw, flag) {
|
|
55
|
+
if (raw === undefined)
|
|
56
|
+
return undefined;
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(raw);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
fail(`${flag} must be a JSON object`, "invalid_args");
|
|
63
|
+
}
|
|
64
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
65
|
+
fail(`${flag} must be a JSON object`, "invalid_args");
|
|
66
|
+
}
|
|
67
|
+
return parsed;
|
|
68
|
+
}
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// create
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
async function runCreate(args) {
|
|
73
|
+
assertKnownFlags(args, ...specFor("connections", "create"));
|
|
74
|
+
const appArg = args.flags.get("app");
|
|
75
|
+
const name = args.flags.get("name");
|
|
76
|
+
const allowedHost = args.flags.get("allowed-host");
|
|
77
|
+
if (!appArg || !name || !allowedHost) {
|
|
78
|
+
fail("usage: homespun connections create --app <idOrSlug> --name <name> --allowed-host <host> [...]", "invalid_args");
|
|
79
|
+
}
|
|
80
|
+
const kind = args.flags.get("kind");
|
|
81
|
+
if (kind !== undefined && kind !== "static" && kind !== "oauth2") {
|
|
82
|
+
fail('--kind must be "static" or "oauth2"', "invalid_args");
|
|
83
|
+
}
|
|
84
|
+
const client = makeClient(args);
|
|
85
|
+
const appId = await resolveAppId(client, appArg);
|
|
86
|
+
try {
|
|
87
|
+
if ((kind ?? "static") === "oauth2") {
|
|
88
|
+
const authorizeUrl = args.flags.get("authorize-url");
|
|
89
|
+
const tokenEndpoint = args.flags.get("token-url");
|
|
90
|
+
const clientId = args.flags.get("client-id");
|
|
91
|
+
const clientSecret = args.flags.get("client-secret");
|
|
92
|
+
if (!authorizeUrl || !tokenEndpoint || !clientId || !clientSecret) {
|
|
93
|
+
fail("kind=oauth2 requires --authorize-url, --token-url, --client-id and --client-secret", "invalid_args");
|
|
94
|
+
}
|
|
95
|
+
printJson(await client.createConnection(appId, {
|
|
96
|
+
name: name,
|
|
97
|
+
kind: "oauth2",
|
|
98
|
+
allowedHost: allowedHost,
|
|
99
|
+
authorizeUrl: authorizeUrl,
|
|
100
|
+
tokenEndpoint: tokenEndpoint,
|
|
101
|
+
clientId: clientId,
|
|
102
|
+
clientSecret: clientSecret,
|
|
103
|
+
...(args.flags.get("provider") !== undefined
|
|
104
|
+
? { provider: args.flags.get("provider") }
|
|
105
|
+
: {}),
|
|
106
|
+
...(args.flags.get("label") !== undefined
|
|
107
|
+
? { label: args.flags.get("label") }
|
|
108
|
+
: {}),
|
|
109
|
+
...(args.flags.get("scopes") !== undefined
|
|
110
|
+
? { scopes: args.flags.get("scopes") }
|
|
111
|
+
: {}),
|
|
112
|
+
...(args.flags.get("auth-scheme") !== undefined
|
|
113
|
+
? { authScheme: args.flags.get("auth-scheme") }
|
|
114
|
+
: {}),
|
|
115
|
+
...(args.flags.get("instance-field") !== undefined
|
|
116
|
+
? { instanceField: args.flags.get("instance-field") }
|
|
117
|
+
: {}),
|
|
118
|
+
...(() => {
|
|
119
|
+
const v = parseJsonObjectFlag(args.flags.get("auth-params"), "--auth-params");
|
|
120
|
+
return v !== undefined ? { authParams: v } : {};
|
|
121
|
+
})(),
|
|
122
|
+
...(() => {
|
|
123
|
+
const v = parseJsonObjectFlag(args.flags.get("token-params"), "--token-params");
|
|
124
|
+
return v !== undefined ? { tokenParams: v } : {};
|
|
125
|
+
})(),
|
|
126
|
+
}));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const headerValue = args.flags.get("header-value");
|
|
130
|
+
if (!headerValue) {
|
|
131
|
+
fail("--header-value is required for a static connection", "invalid_args");
|
|
132
|
+
}
|
|
133
|
+
printJson(await client.createConnection(appId, {
|
|
134
|
+
name: name,
|
|
135
|
+
kind: "static",
|
|
136
|
+
allowedHost: allowedHost,
|
|
137
|
+
headerValue: headerValue,
|
|
138
|
+
headerName: args.flags.get("header-name") ?? "Authorization",
|
|
139
|
+
...(args.flags.get("provider") !== undefined
|
|
140
|
+
? { provider: args.flags.get("provider") }
|
|
141
|
+
: {}),
|
|
142
|
+
...(args.flags.get("label") !== undefined
|
|
143
|
+
? { label: args.flags.get("label") }
|
|
144
|
+
: {}),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
failFromError(e);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// list
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
async function runList(args) {
|
|
155
|
+
assertKnownFlags(args, ...specFor("connections", "list"));
|
|
156
|
+
const appArg = args.flags.get("app");
|
|
157
|
+
if (!appArg) {
|
|
158
|
+
fail("usage: homespun connections list --app <idOrSlug>", "invalid_args");
|
|
159
|
+
}
|
|
160
|
+
const client = makeClient(args);
|
|
161
|
+
const appId = await resolveAppId(client, appArg);
|
|
162
|
+
try {
|
|
163
|
+
printJson(await client.listConnections(appId));
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
failFromError(e);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// delete
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
async function runDelete(args) {
|
|
173
|
+
assertKnownFlags(args, ...specFor("connections", "delete"));
|
|
174
|
+
const appArg = args.flags.get("app");
|
|
175
|
+
const name = args.flags.get("name");
|
|
176
|
+
if (!appArg || !name) {
|
|
177
|
+
fail("usage: homespun connections delete --app <idOrSlug> --name <name>", "invalid_args");
|
|
178
|
+
}
|
|
179
|
+
const client = makeClient(args);
|
|
180
|
+
const appId = await resolveAppId(client, appArg);
|
|
181
|
+
try {
|
|
182
|
+
await client.deleteConnection(appId, name);
|
|
183
|
+
printJson({ deleted: true, app_id: appId, name: name });
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
failFromError(e);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// authorize-url
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
async function runAuthorizeUrl(args) {
|
|
193
|
+
assertKnownFlags(args, ...specFor("connections", "authorize-url"));
|
|
194
|
+
const appArg = args.flags.get("app");
|
|
195
|
+
const name = args.flags.get("name");
|
|
196
|
+
if (!appArg || !name) {
|
|
197
|
+
fail("usage: homespun connections authorize-url --app <idOrSlug> --name <name>", "invalid_args");
|
|
198
|
+
}
|
|
199
|
+
const client = makeClient(args);
|
|
200
|
+
const appId = await resolveAppId(client, appArg);
|
|
201
|
+
printJson({
|
|
202
|
+
app_id: appId,
|
|
203
|
+
name: name,
|
|
204
|
+
authorize_url: client.connectionAuthorizeUrl(appId, name),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// `homespun credentials` (#1354, #1355, #1363) scoped-service-credential
|
|
2
|
+
// management for a v2 app: mint the bearer token an owner points a backend
|
|
3
|
+
// they host themselves at, list an app's credentials, pause/resume one
|
|
4
|
+
// reversibly, rotate one with an overlap window, and revoke one permanently.
|
|
5
|
+
// Every verb targets an app via a required `--app <idOrSlug>` flag, resolved
|
|
6
|
+
// the same way `homespun grants`/`homespun members`/`homespun data` do
|
|
7
|
+
// (resolveAppId).
|
|
8
|
+
//
|
|
9
|
+
// Auth on the relay side is owner-or-owning-agent; this CLI always
|
|
10
|
+
// authenticates as the owning agent, so any verb works for an app the calling
|
|
11
|
+
// agent's owning human owns. A service credential itself can reach none of
|
|
12
|
+
// these routes (the relay's owner-or-agent gate rejects it at the door), so
|
|
13
|
+
// there is no separate authorization check to add here.
|
|
14
|
+
//
|
|
15
|
+
// mint's raw `token` is printed exactly ONCE (only its sha256 is stored, so
|
|
16
|
+
// it is never recoverable afterward); rotate's new token is printed once the
|
|
17
|
+
// same way.
|
|
18
|
+
import { assertKnownFlags } from "../argv.js";
|
|
19
|
+
import { nounSpec, renderNounHelp, specFor } from "../help-catalog.js";
|
|
20
|
+
import { makeClient } from "../config.js";
|
|
21
|
+
import { fail, failFromError, printJson } from "../output.js";
|
|
22
|
+
import { resolveAppId } from "../resolve-app.js";
|
|
23
|
+
export async function runCredential(args) {
|
|
24
|
+
const verb = args.positionals[0];
|
|
25
|
+
if ((verb === undefined || verb === "help") && args.bools.has("help")) {
|
|
26
|
+
process.stdout.write(renderNounHelp(nounSpec("credentials")) + "\n");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (verb === undefined) {
|
|
30
|
+
fail("missing verb: homespun credentials <mint|list|pause|resume|rotate|revoke>", "invalid_args");
|
|
31
|
+
}
|
|
32
|
+
const sub = {
|
|
33
|
+
positionals: args.positionals.slice(1),
|
|
34
|
+
flags: args.flags,
|
|
35
|
+
bools: args.bools,
|
|
36
|
+
...(args.danglingValueFlags !== undefined
|
|
37
|
+
? { danglingValueFlags: args.danglingValueFlags }
|
|
38
|
+
: {}),
|
|
39
|
+
};
|
|
40
|
+
switch (verb) {
|
|
41
|
+
case "mint":
|
|
42
|
+
return runMint(sub);
|
|
43
|
+
case "list":
|
|
44
|
+
return runList(sub);
|
|
45
|
+
case "pause":
|
|
46
|
+
return runPause(sub);
|
|
47
|
+
case "resume":
|
|
48
|
+
return runResume(sub);
|
|
49
|
+
case "rotate":
|
|
50
|
+
return runRotate(sub);
|
|
51
|
+
case "revoke":
|
|
52
|
+
return runRevoke(sub);
|
|
53
|
+
default:
|
|
54
|
+
fail(`unknown verb '${verb}' (homespun credentials <mint|list|pause|resume|rotate|revoke>)`, "invalid_args");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function parseNonNegativeInt(raw, flag) {
|
|
58
|
+
const n = Number(raw);
|
|
59
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
60
|
+
fail(`${flag} must be a non-negative integer`, "invalid_args");
|
|
61
|
+
}
|
|
62
|
+
return n;
|
|
63
|
+
}
|
|
64
|
+
function parsePositiveInt(raw, flag) {
|
|
65
|
+
const n = Number(raw);
|
|
66
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
67
|
+
fail(`${flag} must be a positive integer`, "invalid_args");
|
|
68
|
+
}
|
|
69
|
+
return n;
|
|
70
|
+
}
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// mint
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
async function runMint(args) {
|
|
75
|
+
assertKnownFlags(args, ...specFor("credentials", "mint"));
|
|
76
|
+
const appArg = args.flags.get("app");
|
|
77
|
+
if (!appArg) {
|
|
78
|
+
fail("usage: homespun credentials mint --app <idOrSlug>", "invalid_args");
|
|
79
|
+
}
|
|
80
|
+
const mode = args.flags.get("mode");
|
|
81
|
+
if (mode !== undefined && mode !== "explicit" && mode !== "following") {
|
|
82
|
+
fail('--mode must be "explicit" or "following"', "invalid_args");
|
|
83
|
+
}
|
|
84
|
+
const grantsRaw = args.flags.get("grants");
|
|
85
|
+
let grants;
|
|
86
|
+
if (grantsRaw !== undefined) {
|
|
87
|
+
let parsed;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(grantsRaw);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
fail('--grants must be a JSON array, e.g. \'[{"collection":"orders","ops":["read","create"]}]\'', "invalid_args");
|
|
93
|
+
}
|
|
94
|
+
if (!Array.isArray(parsed)) {
|
|
95
|
+
fail("--grants must be a JSON array of allowlist entries", "invalid_args");
|
|
96
|
+
}
|
|
97
|
+
grants = parsed;
|
|
98
|
+
}
|
|
99
|
+
const ttlRaw = args.flags.get("ttl");
|
|
100
|
+
const noExpiry = args.bools.has("no-expiry");
|
|
101
|
+
if (ttlRaw !== undefined && noExpiry) {
|
|
102
|
+
fail("--ttl and --no-expiry are mutually exclusive", "invalid_args");
|
|
103
|
+
}
|
|
104
|
+
const client = makeClient(args);
|
|
105
|
+
const appId = await resolveAppId(client, appArg);
|
|
106
|
+
try {
|
|
107
|
+
printJson(await client.mintAppCredential(appId, {
|
|
108
|
+
...(mode !== undefined
|
|
109
|
+
? { mode: mode }
|
|
110
|
+
: {}),
|
|
111
|
+
...(grants !== undefined ? { grants } : {}),
|
|
112
|
+
...(args.bools.has("members") ? { members: true } : {}),
|
|
113
|
+
...(args.flags.get("label") !== undefined
|
|
114
|
+
? { label: args.flags.get("label") }
|
|
115
|
+
: {}),
|
|
116
|
+
...(noExpiry
|
|
117
|
+
? { ttlSeconds: null }
|
|
118
|
+
: ttlRaw !== undefined
|
|
119
|
+
? { ttlSeconds: parsePositiveInt(ttlRaw, "--ttl") }
|
|
120
|
+
: {}),
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
failFromError(e);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// list
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
async function runList(args) {
|
|
131
|
+
assertKnownFlags(args, ...specFor("credentials", "list"));
|
|
132
|
+
const appArg = args.flags.get("app");
|
|
133
|
+
if (!appArg) {
|
|
134
|
+
fail("usage: homespun credentials list --app <idOrSlug>", "invalid_args");
|
|
135
|
+
}
|
|
136
|
+
const client = makeClient(args);
|
|
137
|
+
const appId = await resolveAppId(client, appArg);
|
|
138
|
+
try {
|
|
139
|
+
printJson(await client.listAppCredentials(appId));
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
failFromError(e);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// pause / resume
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
function requireCredentialFlags(args, verb) {
|
|
149
|
+
const appArg = args.flags.get("app");
|
|
150
|
+
if (!appArg) {
|
|
151
|
+
fail(`usage: homespun credentials ${verb} --app <idOrSlug> --credential <credentialId>`, "invalid_args");
|
|
152
|
+
}
|
|
153
|
+
const credentialId = args.flags.get("credential");
|
|
154
|
+
if (!credentialId) {
|
|
155
|
+
fail("--credential is required", "invalid_args");
|
|
156
|
+
}
|
|
157
|
+
return { appArg: appArg, credentialId: credentialId };
|
|
158
|
+
}
|
|
159
|
+
async function runPause(args) {
|
|
160
|
+
assertKnownFlags(args, ...specFor("credentials", "pause"));
|
|
161
|
+
const { appArg, credentialId } = requireCredentialFlags(args, "pause");
|
|
162
|
+
const client = makeClient(args);
|
|
163
|
+
const appId = await resolveAppId(client, appArg);
|
|
164
|
+
try {
|
|
165
|
+
await client.pauseAppCredential(appId, credentialId);
|
|
166
|
+
printJson({ paused: true, app_id: appId, credential_id: credentialId });
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
failFromError(e);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function runResume(args) {
|
|
173
|
+
assertKnownFlags(args, ...specFor("credentials", "resume"));
|
|
174
|
+
const { appArg, credentialId } = requireCredentialFlags(args, "resume");
|
|
175
|
+
const client = makeClient(args);
|
|
176
|
+
const appId = await resolveAppId(client, appArg);
|
|
177
|
+
try {
|
|
178
|
+
await client.resumeAppCredential(appId, credentialId);
|
|
179
|
+
printJson({ resumed: true, app_id: appId, credential_id: credentialId });
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
failFromError(e);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// rotate
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
async function runRotate(args) {
|
|
189
|
+
assertKnownFlags(args, ...specFor("credentials", "rotate"));
|
|
190
|
+
const { appArg, credentialId } = requireCredentialFlags(args, "rotate");
|
|
191
|
+
const overlapRaw = args.flags.get("overlap");
|
|
192
|
+
const client = makeClient(args);
|
|
193
|
+
const appId = await resolveAppId(client, appArg);
|
|
194
|
+
try {
|
|
195
|
+
printJson(await client.rotateAppCredential(appId, credentialId, {
|
|
196
|
+
...(overlapRaw !== undefined
|
|
197
|
+
? { overlapSeconds: parseNonNegativeInt(overlapRaw, "--overlap") }
|
|
198
|
+
: {}),
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
failFromError(e);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// revoke
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
async function runRevoke(args) {
|
|
209
|
+
assertKnownFlags(args, ...specFor("credentials", "revoke"));
|
|
210
|
+
const { appArg, credentialId } = requireCredentialFlags(args, "revoke");
|
|
211
|
+
const client = makeClient(args);
|
|
212
|
+
const appId = await resolveAppId(client, appArg);
|
|
213
|
+
try {
|
|
214
|
+
await client.revokeAppCredential(appId, credentialId);
|
|
215
|
+
printJson({ revoked: true, app_id: appId, credential_id: credentialId });
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
failFromError(e);
|
|
219
|
+
}
|
|
220
|
+
}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1,14 +1,174 @@
|
|
|
1
1
|
// `homespun deploy` — create or redeploy an App (spec-cli §3.1). This is the
|
|
2
2
|
// create->redeploy loop the v2 vision names: no `--app` creates a new App;
|
|
3
3
|
// `--app <id>` redeploys an existing one (compat-gated unless --force).
|
|
4
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { makeClient } from "../config.js";
|
|
7
7
|
import { assertKnownFlags } from "../argv.js";
|
|
8
8
|
import { specFor } from "../help-catalog.js";
|
|
9
|
-
import { fail, failFromError, printJson } from "../output.js";
|
|
9
|
+
import { fail, failFromError, printJson, warn } from "../output.js";
|
|
10
10
|
import { resolveJson } from "../input.js";
|
|
11
11
|
import { resolveAppId } from "../resolve-app.js";
|
|
12
|
+
// The subdirectory a directory deploy ships as the relay's multi-file `assets[]`
|
|
13
|
+
// bundle (issue #1225). Deliberately an EXPLICIT directory rather than "every
|
|
14
|
+
// file next to index.html": a whole-directory walk would sweep up node_modules,
|
|
15
|
+
// .git, package.json, lockfiles and source, burn the 50-asset cap on them, and
|
|
16
|
+
// publish source to an origin the whole world can read. `assets/` cannot be
|
|
17
|
+
// triggered by accident.
|
|
18
|
+
const ASSET_DIR = "assets";
|
|
19
|
+
// The reference path KEEPS the `assets/` prefix, so a file at
|
|
20
|
+
// `<dir>/assets/fonts/inter.woff2` is referenced by the page as
|
|
21
|
+
// `assets/fonts/inter.woff2`. What is on disk is what the HTML writes; no
|
|
22
|
+
// prefix-stripping to reason about.
|
|
23
|
+
// Client-side mirrors of the relay's deploy caps (MAX_APP_ASSETS,
|
|
24
|
+
// MAX_BLOB_BYTES in the relay's config). Same pattern as the slug/visibility
|
|
25
|
+
// checks below: fail fast with a message naming the offending file rather than
|
|
26
|
+
// base64 the whole bundle and round-trip a request that will be rejected. The
|
|
27
|
+
// relay re-checks both and stays authoritative.
|
|
28
|
+
const MAX_ASSETS = 50;
|
|
29
|
+
const MAX_ASSET_BYTES = 5_000_000;
|
|
30
|
+
// The relay's asset-path charset (core/app-assets.ts ASSET_PATH_CHARSET).
|
|
31
|
+
// Excludes ':' , whitespace, '%', '\' and every control byte.
|
|
32
|
+
const ASSET_PATH_CHARSET = /^[A-Za-z0-9._/-]+$/;
|
|
33
|
+
// Extensions the relay cannot serve as executable subresources. These have no
|
|
34
|
+
// magic bytes, so they sniff to `application/octet-stream`, pass the upload
|
|
35
|
+
// allowlist, and are then served `Content-Disposition: attachment` with
|
|
36
|
+
// `X-Content-Type-Options: nosniff`, so the browser downloads them instead of
|
|
37
|
+
// running them, and `<script src>` / `<link rel=stylesheet>` silently does
|
|
38
|
+
// nothing. Uploading them would reproduce exactly the silent failure this
|
|
39
|
+
// change exists to remove, so refuse them here with an explanation.
|
|
40
|
+
const NON_SERVABLE_EXTENSIONS = new Set([
|
|
41
|
+
".js",
|
|
42
|
+
".mjs",
|
|
43
|
+
".cjs",
|
|
44
|
+
".jsx",
|
|
45
|
+
".ts",
|
|
46
|
+
".tsx",
|
|
47
|
+
".css",
|
|
48
|
+
".svg",
|
|
49
|
+
".html",
|
|
50
|
+
".htm",
|
|
51
|
+
".xhtml",
|
|
52
|
+
]);
|
|
53
|
+
// Types the relay CANNOT sniff from magic bytes, where a declared `mime` is the
|
|
54
|
+
// only way the asset is stored as itself rather than as octet-stream. For
|
|
55
|
+
// everything else (images, fonts, audio, video, pdf) the mime is omitted
|
|
56
|
+
// deliberately: the relay sniffs the real type, and a declared type that
|
|
57
|
+
// disagrees with the bytes is rejected.
|
|
58
|
+
const DECLARED_MIME_BY_EXTENSION = new Map([
|
|
59
|
+
[".txt", "text/plain"],
|
|
60
|
+
[".csv", "text/csv"],
|
|
61
|
+
[".md", "text/markdown"],
|
|
62
|
+
[".json", "application/json"],
|
|
63
|
+
[".zip", "application/zip"],
|
|
64
|
+
[
|
|
65
|
+
".docx",
|
|
66
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
67
|
+
],
|
|
68
|
+
[
|
|
69
|
+
".xlsx",
|
|
70
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
".pptx",
|
|
74
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
75
|
+
],
|
|
76
|
+
]);
|
|
77
|
+
/** Lowercase extension including the dot, or "" when there is none. */
|
|
78
|
+
function extensionOf(path) {
|
|
79
|
+
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
80
|
+
const dot = base.lastIndexOf(".");
|
|
81
|
+
return dot <= 0 ? "" : base.slice(dot).toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Every file under `<dir>/assets/`, as paths relative to that directory,
|
|
85
|
+
* depth-first and sorted so a deploy is byte-identical across machines.
|
|
86
|
+
*
|
|
87
|
+
* Dot-prefixed entries are skipped at every level: `.DS_Store`, `.gitkeep` and
|
|
88
|
+
* editor droppings are never intended as assets, and the relay's path charset
|
|
89
|
+
* would take them anyway, so skipping is the only way they do not silently
|
|
90
|
+
* consume the asset budget.
|
|
91
|
+
*/
|
|
92
|
+
function walkAssetDir(assetRoot, rel, out) {
|
|
93
|
+
const entries = readdirSync(rel === "" ? assetRoot : join(assetRoot, rel), {
|
|
94
|
+
withFileTypes: true,
|
|
95
|
+
});
|
|
96
|
+
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
97
|
+
if (entry.name.startsWith("."))
|
|
98
|
+
continue;
|
|
99
|
+
const childRel = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
100
|
+
if (entry.isDirectory()) {
|
|
101
|
+
walkAssetDir(assetRoot, childRel, out);
|
|
102
|
+
}
|
|
103
|
+
else if (entry.isFile()) {
|
|
104
|
+
out.push(childRel);
|
|
105
|
+
}
|
|
106
|
+
// Anything else (symlink, socket, fifo) is skipped: not a file we can read
|
|
107
|
+
// bytes from with any confidence about what they are.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Read `<source>/assets/**` into the relay's `assets[]` bundle shape.
|
|
112
|
+
*
|
|
113
|
+
* Returns `undefined` when there is no `assets/` directory at all, which the
|
|
114
|
+
* relay reads as "carry the live asset set forward" on a redeploy. That
|
|
115
|
+
* distinction matters: a directory that never had an `assets/` folder must not
|
|
116
|
+
* wipe assets an agent uploaded through the MCP `deploy_app` path. An `assets/`
|
|
117
|
+
* directory that exists but is empty returns `[]`, the relay's explicit
|
|
118
|
+
* "clear the assets".
|
|
119
|
+
*/
|
|
120
|
+
function readAssets(source) {
|
|
121
|
+
const assetRoot = join(source, ASSET_DIR);
|
|
122
|
+
if (!existsSync(assetRoot) || !statSync(assetRoot).isDirectory()) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const relPaths = [];
|
|
126
|
+
walkAssetDir(assetRoot, "", relPaths);
|
|
127
|
+
if (relPaths.length > MAX_ASSETS) {
|
|
128
|
+
fail(`too many assets: ${ASSET_DIR}/ holds ${relPaths.length} files, the limit is ${MAX_ASSETS} per deploy`, "invalid_args");
|
|
129
|
+
}
|
|
130
|
+
return relPaths.map((rel) => {
|
|
131
|
+
// Mirror of the relay's validateAssetPath charset. A space or an accent in
|
|
132
|
+
// a filename is the common case here, and both are ordinary on disk, so
|
|
133
|
+
// catching it locally turns a server round-trip into an immediate message
|
|
134
|
+
// naming the file. The relay re-validates and stays authoritative.
|
|
135
|
+
if (!ASSET_PATH_CHARSET.test(`${ASSET_DIR}/${rel}`)) {
|
|
136
|
+
fail(`cannot ship ${ASSET_DIR}/${rel} as an asset: an asset path may only contain A-Za-z0-9._/- , so rename the file (spaces and accented characters are the usual cause)`, "invalid_args");
|
|
137
|
+
}
|
|
138
|
+
const ext = extensionOf(rel);
|
|
139
|
+
if (NON_SERVABLE_EXTENSIONS.has(ext)) {
|
|
140
|
+
fail(`cannot ship ${ASSET_DIR}/${rel} as an asset: the relay serves ${ext} files as an inert download (Content-Disposition: attachment, X-Content-Type-Options: nosniff), so a browser would refuse to execute or apply it. Inline scripts and styles in index.html instead: the app CSP allows them.`, "invalid_args");
|
|
141
|
+
}
|
|
142
|
+
const bytes = readFileSync(join(assetRoot, rel));
|
|
143
|
+
if (bytes.byteLength > MAX_ASSET_BYTES) {
|
|
144
|
+
fail(`asset ${ASSET_DIR}/${rel} is ${bytes.byteLength} bytes, over the ${MAX_ASSET_BYTES}-byte per-file limit`, "invalid_args");
|
|
145
|
+
}
|
|
146
|
+
const mime = DECLARED_MIME_BY_EXTENSION.get(ext);
|
|
147
|
+
return {
|
|
148
|
+
path: `${ASSET_DIR}/${rel}`,
|
|
149
|
+
content_base64: bytes.toString("base64"),
|
|
150
|
+
...(mime !== undefined ? { mime } : {}),
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Warn about files sitting next to `index.html` that this deploy is NOT
|
|
156
|
+
* shipping. Silently dropping them is the failure mode issue #1225 is about:
|
|
157
|
+
* the deploy succeeds, and the app 404s on a file the author can plainly see in
|
|
158
|
+
* the directory.
|
|
159
|
+
*/
|
|
160
|
+
function warnAboutIgnoredFiles(source) {
|
|
161
|
+
const ignored = readdirSync(source, { withFileTypes: true })
|
|
162
|
+
.filter((e) => !e.name.startsWith(".") &&
|
|
163
|
+
e.name !== "index.html" &&
|
|
164
|
+
e.name !== "manifest.json" &&
|
|
165
|
+
!(e.isDirectory() && e.name === ASSET_DIR))
|
|
166
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
|
167
|
+
.sort();
|
|
168
|
+
if (ignored.length === 0)
|
|
169
|
+
return;
|
|
170
|
+
warn(`not deploying ${ignored.length} entr${ignored.length === 1 ? "y" : "ies"} in ${source}: ${ignored.join(", ")}. A directory deploy ships index.html, manifest.json and everything under ${ASSET_DIR}/. Move files you want served into ${ASSET_DIR}/.`);
|
|
171
|
+
}
|
|
12
172
|
function readBundle(source, manifestFlag, isRedeploy) {
|
|
13
173
|
// No source at all: only a redeploy can do this, and only to change the
|
|
14
174
|
// manifest alone (the live document is inherited).
|
|
@@ -39,9 +199,12 @@ function readBundle(source, manifestFlag, isRedeploy) {
|
|
|
39
199
|
if (missing.length > 0) {
|
|
40
200
|
fail(`directory deploy is missing required file(s): ${missing.join(", ")}`, "invalid_args");
|
|
41
201
|
}
|
|
202
|
+
warnAboutIgnoredFiles(source);
|
|
203
|
+
const assets = readAssets(source);
|
|
42
204
|
return {
|
|
43
205
|
html: readFileSync(htmlPath, "utf8"),
|
|
44
206
|
manifest: JSON.parse(readFileSync(manifestPath, "utf8")),
|
|
207
|
+
...(assets !== undefined ? { assets } : {}),
|
|
45
208
|
};
|
|
46
209
|
}
|
|
47
210
|
// Single-file escape hatch. A create must pair it with --manifest; a
|
|
@@ -81,6 +244,7 @@ export async function runDeploy(args) {
|
|
|
81
244
|
...(id !== undefined ? { app_id: id } : {}),
|
|
82
245
|
...(bundle.html !== undefined ? { html: bundle.html } : {}),
|
|
83
246
|
...(bundle.manifest !== undefined ? { manifest: bundle.manifest } : {}),
|
|
247
|
+
...(bundle.assets !== undefined ? { assets: bundle.assets } : {}),
|
|
84
248
|
...(force ? { force } : {}),
|
|
85
249
|
});
|
|
86
250
|
printJson(result);
|
|
@@ -105,6 +269,7 @@ export async function runDeploy(args) {
|
|
|
105
269
|
manifest: bundle.manifest,
|
|
106
270
|
visibility,
|
|
107
271
|
slug,
|
|
272
|
+
...(bundle.assets !== undefined ? { assets: bundle.assets } : {}),
|
|
108
273
|
});
|
|
109
274
|
printJson(out);
|
|
110
275
|
}
|
|
@@ -122,13 +287,17 @@ export async function runDeploy(args) {
|
|
|
122
287
|
}
|
|
123
288
|
const id = await resolveAppId(client, appId);
|
|
124
289
|
try {
|
|
125
|
-
// Only what this invocation actually read is sent: an omitted html
|
|
126
|
-
// manifest keeps what is live, so
|
|
127
|
-
// ships the document alone and
|
|
128
|
-
// manifest alone.
|
|
290
|
+
// Only what this invocation actually read is sent: an omitted html,
|
|
291
|
+
// manifest or asset set keeps what is live, so
|
|
292
|
+
// `homespun deploy ./index.html --app <id>` ships the document alone and
|
|
293
|
+
// `--manifest` with no file ships the manifest alone. A directory WITH an
|
|
294
|
+
// `assets/` folder always sends the full computed set, so deleting a file
|
|
295
|
+
// on disk removes it from the app; a directory WITHOUT one sends nothing,
|
|
296
|
+
// leaving assets uploaded by another path (MCP `deploy_app`) untouched.
|
|
129
297
|
const redeployed = await client.redeployApp(id, {
|
|
130
298
|
...(bundle.html !== undefined ? { html: bundle.html } : {}),
|
|
131
299
|
...(bundle.manifest !== undefined ? { manifest: bundle.manifest } : {}),
|
|
300
|
+
...(bundle.assets !== undefined ? { assets: bundle.assets } : {}),
|
|
132
301
|
force,
|
|
133
302
|
});
|
|
134
303
|
const app = await client.getApp(id);
|
package/dist/help-catalog.js
CHANGED
|
@@ -610,6 +610,289 @@ const GRANTS = {
|
|
|
610
610
|
"revoke is idempotent; a revoked link is rejected on every subsequent request.",
|
|
611
611
|
],
|
|
612
612
|
};
|
|
613
|
+
const CREDENTIALS = {
|
|
614
|
+
noun: "credentials",
|
|
615
|
+
tagline: "scoped service credential management",
|
|
616
|
+
group: "app",
|
|
617
|
+
rootSummary: "App service-credential management: mint, list, pause, resume, rotate, revoke. Mint the bearer token an owner points a backend they host themselves at.",
|
|
618
|
+
verbs: [
|
|
619
|
+
{
|
|
620
|
+
verb: "mint",
|
|
621
|
+
summary: "Mints a scoped service credential and prints its raw token once.",
|
|
622
|
+
flags: [
|
|
623
|
+
{
|
|
624
|
+
name: "app",
|
|
625
|
+
value: "<idOrSlug>",
|
|
626
|
+
description: "App to mint the credential for (required)",
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
name: "mode",
|
|
630
|
+
value: "<explicit|following>",
|
|
631
|
+
description: "explicit (default): an unnamed collection is denied. following: an unnamed collection falls through to the owner's authority and each grant only narrows",
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
name: "grants",
|
|
635
|
+
value: "<json>",
|
|
636
|
+
description: "The allowlist as a JSON array of {collection, ops, scope?} entries (see notes)",
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
name: "label",
|
|
640
|
+
value: "<text>",
|
|
641
|
+
description: "Human-readable label for the credential",
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
name: "ttl",
|
|
645
|
+
value: "<seconds>",
|
|
646
|
+
description: "Lifetime in seconds, default 365 days and clamped to the server max",
|
|
647
|
+
},
|
|
648
|
+
],
|
|
649
|
+
bools: [
|
|
650
|
+
{
|
|
651
|
+
name: "members",
|
|
652
|
+
description: "Opt this credential into the app's member directory (default off)",
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
name: "no-expiry",
|
|
656
|
+
description: "NO EXPIRY, an explicit opt-in for a long-running backend. Mutually exclusive with --ttl",
|
|
657
|
+
},
|
|
658
|
+
],
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
verb: "list",
|
|
662
|
+
summary: "Lists the app's service credentials.",
|
|
663
|
+
flags: [
|
|
664
|
+
{
|
|
665
|
+
name: "app",
|
|
666
|
+
value: "<idOrSlug>",
|
|
667
|
+
description: "App to list credentials for (required)",
|
|
668
|
+
},
|
|
669
|
+
],
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
verb: "pause",
|
|
673
|
+
summary: "Reversibly stops one credential.",
|
|
674
|
+
flags: [
|
|
675
|
+
{
|
|
676
|
+
name: "app",
|
|
677
|
+
value: "<idOrSlug>",
|
|
678
|
+
description: "App the credential belongs to (required)",
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
name: "credential",
|
|
682
|
+
value: "<credentialId>",
|
|
683
|
+
description: "Credential to pause (required)",
|
|
684
|
+
},
|
|
685
|
+
],
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
verb: "resume",
|
|
689
|
+
summary: "Undoes a pause. Never undoes a revoke, which is permanent.",
|
|
690
|
+
flags: [
|
|
691
|
+
{
|
|
692
|
+
name: "app",
|
|
693
|
+
value: "<idOrSlug>",
|
|
694
|
+
description: "App the credential belongs to (required)",
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
name: "credential",
|
|
698
|
+
value: "<credentialId>",
|
|
699
|
+
description: "Credential to resume (required)",
|
|
700
|
+
},
|
|
701
|
+
],
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
verb: "rotate",
|
|
705
|
+
summary: "Issues a fresh token and keeps the old one live for an overlap window, printing the new token once.",
|
|
706
|
+
flags: [
|
|
707
|
+
{
|
|
708
|
+
name: "app",
|
|
709
|
+
value: "<idOrSlug>",
|
|
710
|
+
description: "App the credential belongs to (required)",
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
name: "credential",
|
|
714
|
+
value: "<credentialId>",
|
|
715
|
+
description: "Credential to rotate (required)",
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
name: "overlap",
|
|
719
|
+
value: "<seconds>",
|
|
720
|
+
description: "How long the superseded token keeps working (default 1 day, clamped to a server max); 0 kills it immediately",
|
|
721
|
+
},
|
|
722
|
+
],
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
verb: "revoke",
|
|
726
|
+
summary: "Revokes one credential permanently.",
|
|
727
|
+
flags: [
|
|
728
|
+
{
|
|
729
|
+
name: "app",
|
|
730
|
+
value: "<idOrSlug>",
|
|
731
|
+
description: "App the credential belongs to (required)",
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
name: "credential",
|
|
735
|
+
value: "<credentialId>",
|
|
736
|
+
description: "Credential to revoke (required)",
|
|
737
|
+
},
|
|
738
|
+
],
|
|
739
|
+
},
|
|
740
|
+
],
|
|
741
|
+
notes: [
|
|
742
|
+
"--app accepts either the app_id or its slug (resolved via GET /v1/apps?slug= when it does not look like a cuid).",
|
|
743
|
+
"A credential is the bearer token an app owner points a backend they host themselves at. Effective permission is always the intersection of the allowlist and what the app's owner could do, so a credential can only ever narrow, never widen, and it carries no role.",
|
|
744
|
+
"mint's raw token is shown ONCE in the response and is never recoverable afterward (only its sha256 is stored); if it is lost, rotate or mint a new one. --grants names each collection the credential may reach and which of read/create/update/delete it may attempt there; a collection not named is denied under --mode explicit (the default) and falls through to the owner's own authority under --mode following. An entry may set a `scope` of \"own\" to narrow every row-addressed op to rows the credential itself wrote last.",
|
|
745
|
+
"pause is reversible; resume undoes it. revoke is permanent and idempotent, and also kills any token a rotation left inside its overlap window. Every verb here is owner-or-owning-agent only: a service credential itself can reach none of these.",
|
|
746
|
+
],
|
|
747
|
+
};
|
|
748
|
+
const CONNECTIONS = {
|
|
749
|
+
noun: "connections",
|
|
750
|
+
tagline: "webhook connection management",
|
|
751
|
+
group: "app",
|
|
752
|
+
rootSummary: "App webhook-connection management: create, list, delete, authorize-url. Store the credential a webhook rule authenticates its target with.",
|
|
753
|
+
verbs: [
|
|
754
|
+
{
|
|
755
|
+
verb: "create",
|
|
756
|
+
summary: "Creates a static or oauth2 connection.",
|
|
757
|
+
flags: [
|
|
758
|
+
{
|
|
759
|
+
name: "app",
|
|
760
|
+
value: "<idOrSlug>",
|
|
761
|
+
description: "App to create the connection on (required)",
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
name: "name",
|
|
765
|
+
value: "<name>",
|
|
766
|
+
description: "Connection name a manifest webhook rule's `connection` field references (required)",
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
name: "allowed-host",
|
|
770
|
+
value: "<host>",
|
|
771
|
+
description: "Host-binding: an exact DNS host or a single leftmost '*.' wildcard (required)",
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
name: "kind",
|
|
775
|
+
value: "<static|oauth2>",
|
|
776
|
+
description: "Defaults to static",
|
|
777
|
+
},
|
|
778
|
+
{
|
|
779
|
+
name: "provider",
|
|
780
|
+
value: "<text>",
|
|
781
|
+
description: 'Freeform display label, e.g. "hubspot"',
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
name: "label",
|
|
785
|
+
value: "<text>",
|
|
786
|
+
description: "Human-readable label for the connection",
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
name: "header-name",
|
|
790
|
+
value: "<name>",
|
|
791
|
+
description: "static only. Defaults to Authorization",
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
name: "header-value",
|
|
795
|
+
value: "<value>",
|
|
796
|
+
description: 'static only, required. The header value to send, e.g. "Bearer sk_live_..."',
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
name: "authorize-url",
|
|
800
|
+
value: "<url>",
|
|
801
|
+
description: "oauth2 only, required. The provider's authorize endpoint",
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
name: "token-url",
|
|
805
|
+
value: "<url>",
|
|
806
|
+
description: "oauth2 only, required. The provider's token endpoint",
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
name: "client-id",
|
|
810
|
+
value: "<id>",
|
|
811
|
+
description: "oauth2 only, required. Your OAuth2 app's client id",
|
|
812
|
+
},
|
|
813
|
+
{
|
|
814
|
+
name: "client-secret",
|
|
815
|
+
value: "<secret>",
|
|
816
|
+
description: "oauth2 only, required. Your OAuth2 app's client secret",
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
name: "scopes",
|
|
820
|
+
value: "<text>",
|
|
821
|
+
description: "oauth2 only. Space-delimited scopes for the authorize request",
|
|
822
|
+
},
|
|
823
|
+
{
|
|
824
|
+
name: "auth-scheme",
|
|
825
|
+
value: "<text>",
|
|
826
|
+
description: 'oauth2 only. Defaults to "Bearer"',
|
|
827
|
+
},
|
|
828
|
+
{
|
|
829
|
+
name: "instance-field",
|
|
830
|
+
value: "<field>",
|
|
831
|
+
description: 'oauth2 only. Token-response JSON field holding the API base URL, e.g. "instance_url"',
|
|
832
|
+
},
|
|
833
|
+
{
|
|
834
|
+
name: "auth-params",
|
|
835
|
+
value: "<json>",
|
|
836
|
+
description: "oauth2 only. Extra key/values merged into the authorize redirect",
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
name: "token-params",
|
|
840
|
+
value: "<json>",
|
|
841
|
+
description: "oauth2 only. Extra key/values merged into the token POST",
|
|
842
|
+
},
|
|
843
|
+
],
|
|
844
|
+
},
|
|
845
|
+
{
|
|
846
|
+
verb: "list",
|
|
847
|
+
summary: "Lists the app's connections as metadata plus a fingerprint, never a secret.",
|
|
848
|
+
flags: [
|
|
849
|
+
{
|
|
850
|
+
name: "app",
|
|
851
|
+
value: "<idOrSlug>",
|
|
852
|
+
description: "App to list connections for (required)",
|
|
853
|
+
},
|
|
854
|
+
],
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
verb: "delete",
|
|
858
|
+
summary: "Deletes a connection, idempotently.",
|
|
859
|
+
flags: [
|
|
860
|
+
{
|
|
861
|
+
name: "app",
|
|
862
|
+
value: "<idOrSlug>",
|
|
863
|
+
description: "App the connection belongs to (required)",
|
|
864
|
+
},
|
|
865
|
+
{
|
|
866
|
+
name: "name",
|
|
867
|
+
value: "<name>",
|
|
868
|
+
description: "Connection to delete (required)",
|
|
869
|
+
},
|
|
870
|
+
],
|
|
871
|
+
},
|
|
872
|
+
{
|
|
873
|
+
verb: "authorize-url",
|
|
874
|
+
summary: "Prints the browser URL that completes an oauth2 connection's owner consent. Never fetched by this command.",
|
|
875
|
+
flags: [
|
|
876
|
+
{
|
|
877
|
+
name: "app",
|
|
878
|
+
value: "<idOrSlug>",
|
|
879
|
+
description: "App the connection belongs to (required)",
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
name: "name",
|
|
883
|
+
value: "<name>",
|
|
884
|
+
description: "oauth2 connection to build the URL for (required)",
|
|
885
|
+
},
|
|
886
|
+
],
|
|
887
|
+
},
|
|
888
|
+
],
|
|
889
|
+
notes: [
|
|
890
|
+
"--app accepts either the app_id or its slug (resolved via GET /v1/apps?slug= when it does not look like a cuid).",
|
|
891
|
+
"A connection is the stored credential a manifest webhook rule authenticates its delivery target with, bound to a host so it can never be sent to another one. There is no update verb: change a connection by deleting and recreating it.",
|
|
892
|
+
"Every stored secret (a static header value, or an oauth2 client secret and its tokens) is encrypted at rest and never returned by any call; list returns metadata plus a non-reversible fingerprint only.",
|
|
893
|
+
"OAuth2 consent is inherently a human-in-a-browser step: the relay refuses an agent-key caller at the authorize endpoint. authorize-url never makes a network call, it builds the URL locally so you can hand it to the signed-in app owner to open. A newly created oauth2 connection starts in pending_auth until the owner completes it.",
|
|
894
|
+
],
|
|
895
|
+
};
|
|
613
896
|
const KEY = {
|
|
614
897
|
noun: "key",
|
|
615
898
|
tagline: "your agent's API key",
|
|
@@ -675,9 +958,9 @@ const TASTE = {
|
|
|
675
958
|
};
|
|
676
959
|
const FEEDBACK = {
|
|
677
960
|
noun: "feedback",
|
|
678
|
-
tagline: "
|
|
961
|
+
tagline: "report a problem with homespun itself",
|
|
679
962
|
group: "other",
|
|
680
|
-
rootSummary: "
|
|
963
|
+
rootSummary: "Report a problem with homespun itself to the relay operator: create, list. Bug reports, feature requests, and notes.",
|
|
681
964
|
verbs: [
|
|
682
965
|
{
|
|
683
966
|
verb: "create",
|
|
@@ -718,8 +1001,12 @@ const FEEDBACK = {
|
|
|
718
1001
|
},
|
|
719
1002
|
],
|
|
720
1003
|
notes: [
|
|
721
|
-
"Feedback is a one-shot bug report, feature request, or note from your agent to whoever runs the relay. Submissions are stored in the relay DB and the operator triages them out of band.",
|
|
722
|
-
"
|
|
1004
|
+
"Feedback is a one-shot bug report, feature request, or note from your agent to whoever runs the relay. Submissions are stored in the relay DB and the operator triages them out of band. This is the channel that turns a problem you hit into a problem that gets fixed, so file without being asked: nobody will prompt you, and an unreported failure is one the operator cannot see.",
|
|
1005
|
+
"File a report when: a command returns a 5xx or an error code the skill does not describe; documented and observed behaviour disagree; what you need cannot be expressed at all (a missing capability, a self-contradictory schema); a deployed app misbehaves in a way that traces back to the platform rather than to HTML you wrote; or the skill was wrong, ambiguous or silent and you had to guess. An error envelope carrying a `report` field is the CLI telling you the failure was homespun's, not yours.",
|
|
1006
|
+
"Do not file: problems with the human's own task, or bugs in an app you authored; presentation preferences, which belong in 'homespun taste'; the human's own configuration, such as a missing API key or the wrong account; or a 4xx caused by arguments you got wrong, unless the error message itself sent you the wrong way, which is a documentation bug worth --type note.",
|
|
1007
|
+
"Report once, not once per retry. Run 'homespun feedback list' first and skip anything already recorded; one report per distinct failure per session. An agent in a retry loop filing the same row twenty times buries the signal it was trying to send.",
|
|
1008
|
+
"Say enough that it can be fixed without you: the operator sees the row, not your session, so \"deploy failed\" is unactionable. Structure the message as surface (mcp|cli|relay|app-runtime); where (the command or route); versions (cli from 'homespun --version', skill from 'homespun skill version'); expected, one line; observed, one line carrying the exact error code and message; repro, the minimal steps or the arguments you passed. Pipe it in with --message - rather than fighting shell quoting.",
|
|
1009
|
+
"There is no reply channel, so never use feedback for anything you need an answer to. create does not echo the message back. list prints { items: [...], next_before } so you can pass --before <cursor> from a previous page to fetch the next one.",
|
|
723
1010
|
],
|
|
724
1011
|
};
|
|
725
1012
|
const CONFIG = {
|
|
@@ -846,8 +1133,9 @@ const DEPLOY = {
|
|
|
846
1133
|
],
|
|
847
1134
|
notes: [
|
|
848
1135
|
"Packaging has one canonical shape and one escape hatch. A directory deploy (homespun deploy ./my-app) reads ./my-app/index.html and ./my-app/manifest.json: fixed filenames, no discovery heuristics, and both files are required. The single-file escape hatch (homespun deploy ./index.html --manifest ./manifest.json) takes the manifest from --manifest, which accepts a file path or inline JSON.",
|
|
1136
|
+
"Files to serve alongside the document go in ./my-app/assets/. Everything under it ships as the deploy's asset bundle and is served on the app's own origin at the same path, so ./my-app/assets/fonts/inter.woff2 is referenced by the page as assets/fonts/inter.woff2. Nested directories are kept, dot-prefixed entries are skipped, and the limits are 50 files at up to 5 MB each. Only assets/ is shipped: anything else sitting next to index.html is reported on stderr and left behind, so a stray node_modules or package.json is never published. Scripts and stylesheets cannot be assets. The relay serves .js, .css and .svg as an inert download, so a browser would refuse to run or apply them; the app CSP allows inline script and style, so put them in index.html.",
|
|
849
1137
|
"Create versus redeploy is decided by the presence of --app, not by two verbs. With no --app this creates an app (POST /v1/apps); new apps default to private (owner plus invited members, sign-in gated), --slug is accepted with private or public visibility including the default, and an explicit --visibility link always gets a server-generated slug and rejects --slug. With --app <id> this redeploys (POST /v1/apps/:id/versions), where --slug and --visibility are rejected because the slug is immutable and visibility changes go through 'homespun apps update'.",
|
|
850
|
-
"On redeploy, what you do not send is kept. 'homespun deploy ./index.html --app <id>' ships the document alone and keeps the live manifest; 'homespun deploy --app <id> --manifest ./manifest.json' ships the manifest alone and keeps the live document, with no file argument at all; a directory deploy still ships both.
|
|
1138
|
+
"On redeploy, what you do not send is kept. 'homespun deploy ./index.html --app <id>' ships the document alone and keeps the live manifest; 'homespun deploy --app <id> --manifest ./manifest.json' ships the manifest alone and keeps the live document, with no file argument at all; a directory deploy still ships both. Assets follow the same rule, decided by whether the directory has an assets/ folder: with one, the full set on disk is sent, so deleting a file there removes it from the app; with none, nothing is sent and the live asset set is carried forward untouched. A create can inherit nothing, so it still needs both halves.",
|
|
851
1139
|
"--check is a dry run. It runs the full manifest and asset validation (shape and MIME), the redeploy compat gate (with --app), and the schedule-timezone advisory, then prints { ok, warnings, compat, breaks } without creating a version or mutating anything. An invalid manifest fails the same way a real deploy would, and a redeploy the compat gate would refuse reports the break instead of applying it. It resolves omitted fields exactly as a real redeploy would, so it reports on the deploy that would actually run.",
|
|
852
1140
|
],
|
|
853
1141
|
outputNote: 'Output is JSON: { app_id, slug, url, version, visibility, created, share_url, compat, breaks, warnings }. share_url is present only when creating a link-visibility app: it carries the app share token in its #k= fragment and is shown ONCE, it is not recoverable later, and it can be rotated with \'homespun apps share-link rotate <app>\'. warnings flags non-fatal issues, for example an app that declares schedules with no timezone set (reminders fire at 08:00 UTC until one is set). Errors go to stderr as {"error":{"code","message"}} with a non-zero exit.',
|
|
@@ -1277,6 +1565,8 @@ const NOUNS = [
|
|
|
1277
1565
|
DATA,
|
|
1278
1566
|
MEMBERS,
|
|
1279
1567
|
GRANTS,
|
|
1568
|
+
CREDENTIALS,
|
|
1569
|
+
CONNECTIONS,
|
|
1280
1570
|
INGEST,
|
|
1281
1571
|
PUBLISHER,
|
|
1282
1572
|
TEMPLATE,
|
package/dist/index.js
CHANGED
|
@@ -38,6 +38,8 @@ import { runApps } from "./commands/apps.js";
|
|
|
38
38
|
import { runData } from "./commands/data.js";
|
|
39
39
|
import { runMembers } from "./commands/members.js";
|
|
40
40
|
import { runGrant } from "./commands/grant.js";
|
|
41
|
+
import { runCredential } from "./commands/credential.js";
|
|
42
|
+
import { runConnection } from "./commands/connection.js";
|
|
41
43
|
import { runIngest } from "./commands/ingest.js";
|
|
42
44
|
import { runPublisher } from "./commands/publisher.js";
|
|
43
45
|
import { runTemplate } from "./commands/template.js";
|
|
@@ -129,6 +131,12 @@ async function main() {
|
|
|
129
131
|
case "grants":
|
|
130
132
|
await runGrant(args);
|
|
131
133
|
break;
|
|
134
|
+
case "credentials":
|
|
135
|
+
await runCredential(args);
|
|
136
|
+
break;
|
|
137
|
+
case "connections":
|
|
138
|
+
await runConnection(args);
|
|
139
|
+
break;
|
|
132
140
|
case "ingest":
|
|
133
141
|
await runIngest(args);
|
|
134
142
|
break;
|
package/dist/output.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
// stdout/stderr helpers. The CLI is JSON-by-default: machine-readable on
|
|
2
2
|
// stdout, human errors on stderr.
|
|
3
|
-
import { HomespunApiError } from "@homespunapps/core";
|
|
3
|
+
import { HomespunApiError, RELAY_FAILURE_REPORT_HINT, } from "@homespunapps/core";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { detectInstallMethod, upgradeCommandFor, formatUpgradeMessage, EXIT_CLI_UPGRADE_REQUIRED, } from "./upgrade.js";
|
|
6
6
|
/** Print a value as pretty JSON to stdout. */
|
|
7
7
|
export function printJson(value) {
|
|
8
8
|
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Print a non-fatal advisory to STDERR. Never stdout: stdout carries the JSON
|
|
12
|
+
* result a caller pipes into `jq`, and a warning mixed into it would make that
|
|
13
|
+
* output unparseable.
|
|
14
|
+
*/
|
|
15
|
+
export function warn(message) {
|
|
16
|
+
process.stderr.write(`warning: ${message}\n`);
|
|
17
|
+
}
|
|
10
18
|
/**
|
|
11
19
|
* Print a single compact JSON line to stdout and flush. Used by `homespun watch`
|
|
12
20
|
* so a pipe-reader (e.g. Claude Code's Monitor tool) sees each event
|
|
@@ -24,6 +32,8 @@ export function fail(message, code = "error", details, extra) {
|
|
|
24
32
|
error["retryable"] = extra.retryable;
|
|
25
33
|
if (extra?.docs_url !== undefined)
|
|
26
34
|
error["docs_url"] = extra.docs_url;
|
|
35
|
+
if (extra?.report !== undefined)
|
|
36
|
+
error["report"] = extra.report;
|
|
27
37
|
if (details !== undefined)
|
|
28
38
|
error["details"] = details;
|
|
29
39
|
process.stderr.write(JSON.stringify({ error }) + "\n");
|
|
@@ -46,6 +56,11 @@ export function failFromError(err) {
|
|
|
46
56
|
hint: err.hint,
|
|
47
57
|
retryable: err.retryable,
|
|
48
58
|
docs_url: err.docsUrl,
|
|
59
|
+
// 5xx only: the relay failed, so the agent is the only witness and the
|
|
60
|
+
// report is worth prompting. A 4xx is normally the caller's own bad
|
|
61
|
+
// argument, and prompting there would fill the operator's queue with
|
|
62
|
+
// rows they have to triage and close.
|
|
63
|
+
...(err.status >= 500 ? { report: RELAY_FAILURE_REPORT_HINT } : {}),
|
|
49
64
|
});
|
|
50
65
|
}
|
|
51
66
|
fail(err instanceof Error ? err.message : String(err), "internal");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homespunapps/cli",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.45",
|
|
4
4
|
"description": "Command-line client for the Homespun relay: deploy a real multi-user web app from your agent, then keep reading and writing its data.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"test:unit": "vitest run"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@homespunapps/core": "^1.6.
|
|
39
|
+
"@homespunapps/core": "^1.6.45",
|
|
40
40
|
"qrcode-terminal": "^0.12.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|