@miakapp/cli 4.0.0-alpha.0 → 4.0.0-alpha.10
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 +151 -35
- package/assets/agent-guide.md +232 -79
- package/assets/design/OFL-Manrope.txt +93 -0
- package/assets/design/components.css +139 -0
- package/assets/design/fonts.css +9 -0
- package/assets/design/gallery.html +667 -0
- package/assets/design/guide.md +77 -0
- package/assets/design/tokens.css +185 -0
- package/assets/starter/app/app/README.md +74 -0
- package/assets/starter/app/app/main.ts +124 -0
- package/assets/starter/app/app/miakapp.ts +156 -0
- package/assets/starter/app/app/theme.ts +8 -0
- package/assets/starter/app/tsconfig.json +23 -0
- package/bin/miakapp.js +5 -1
- package/dist/agent-pack.d.ts +11 -3
- package/dist/agent-pack.d.ts.map +1 -1
- package/dist/agent-pack.js +26 -10
- package/dist/agent-pack.js.map +1 -1
- package/dist/contexts.d.ts +59 -0
- package/dist/contexts.d.ts.map +1 -0
- package/dist/contexts.js +388 -0
- package/dist/contexts.js.map +1 -0
- package/dist/control-plane.d.ts +14 -0
- package/dist/control-plane.d.ts.map +1 -1
- package/dist/control-plane.js +33 -1
- package/dist/control-plane.js.map +1 -1
- package/dist/internal/names.d.ts +5 -0
- package/dist/internal/names.d.ts.map +1 -1
- package/dist/internal/names.js +6 -0
- package/dist/internal/names.js.map +1 -1
- package/dist/main.d.ts +22 -5
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +673 -60
- package/dist/main.js.map +1 -1
- package/dist/mcp.d.ts +7 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +195 -26
- package/dist/mcp.js.map +1 -1
- package/dist/pairing.d.ts +27 -0
- package/dist/pairing.d.ts.map +1 -0
- package/dist/pairing.js +148 -0
- package/dist/pairing.js.map +1 -0
- package/dist/project.d.ts +3 -1
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +33 -6
- package/dist/project.js.map +1 -1
- package/dist/publication.d.ts +20 -5
- package/dist/publication.d.ts.map +1 -1
- package/dist/publication.js +58 -16
- package/dist/publication.js.map +1 -1
- package/dist/starter.d.ts +17 -0
- package/dist/starter.d.ts.map +1 -0
- package/dist/starter.js +72 -0
- package/dist/starter.js.map +1 -0
- package/dist/terminal.d.ts +10 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +70 -0
- package/dist/terminal.js.map +1 -0
- package/dist/version.d.ts +15 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +15 -0
- package/dist/version.js.map +1 -0
- package/package.json +6 -4
package/dist/main.js
CHANGED
|
@@ -14,31 +14,47 @@
|
|
|
14
14
|
* {@link EXIT_CODE}), so a wrapper decides without parsing prose;
|
|
15
15
|
* - `--json`, which prints exactly one closed object on stdout.
|
|
16
16
|
*/
|
|
17
|
+
import { homedir, hostname } from 'node:os';
|
|
17
18
|
import { installPack } from './agent-pack.js';
|
|
19
|
+
import { CLI_VERSION } from './version.js';
|
|
18
20
|
import { prepareArtifact } from './artifact.js';
|
|
19
|
-
import { exchangePublisherToken, fetchDiscovery } from './control-plane.js';
|
|
21
|
+
import { exchangePublisherToken, fetchDiscovery, homeKeyId, homeUrl } from './control-plane.js';
|
|
22
|
+
import { configDirectory, contextNameError, isContextName, mutateContexts, readContexts, readCredential, } from './contexts.js';
|
|
20
23
|
import { discoverFlows, inventoryJson } from './discovery.js';
|
|
21
|
-
import { CliError, EXIT_CODE, artifactError, authorizationError, projectError, usageError, } from './errors.js';
|
|
24
|
+
import { CliError, EXIT_CODE, artifactError, authorizationError, contractError, projectError, usageError, } from './errors.js';
|
|
22
25
|
import { isDigest, isRelease, REQUIREMENT_KINDS } from './internal/names.js';
|
|
26
|
+
import { DEFAULT_ISSUER, PAIRING_PAGE, normalizeCode, pairingIssuer, redeemPairingCode, validateLabel, } from './pairing.js';
|
|
27
|
+
import { STARTERS, STARTER_FILES, isStarter, starterFiles, validateStarterArtifact, } from './starter.js';
|
|
23
28
|
import { PROJECT_FILE, PROJECT_SCHEMA, findProjectFile, parseProject } from './project.js';
|
|
24
|
-
import { activateRelease, publish, readRelease, readUpload, } from './publication.js';
|
|
25
|
-
export
|
|
29
|
+
import { activateRelease, publish, readPointer, readRelease, readUpload, } from './publication.js';
|
|
30
|
+
export { CLI_VERSION };
|
|
26
31
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* logs, so no command
|
|
32
|
+
* A Home Key comes from a paired context in `~/.miakapp` or, for CI and
|
|
33
|
+
* compatibility, from this variable. A secret passed as an argument would land
|
|
34
|
+
* in shell history, in a process listing and in most CI logs, so no command
|
|
35
|
+
* accepts one.
|
|
30
36
|
*/
|
|
31
37
|
export const HOME_KEY_VARIABLE = 'MIAKAPP_HOME_KEY';
|
|
32
|
-
|
|
38
|
+
/** Selects a stored context for one invocation, like `--context`. */
|
|
39
|
+
export const CONTEXT_VARIABLE = 'MIAKAPP_CONTEXT';
|
|
40
|
+
const USAGE = `miakapp ${CLI_VERSION} — build, publish and roll back a Miakapp home interface
|
|
33
41
|
|
|
34
42
|
Usage
|
|
35
43
|
miakapp <command> [options]
|
|
36
44
|
|
|
37
45
|
Commands
|
|
38
|
-
|
|
46
|
+
docs start Print the complete agent guide bundled with this CLI
|
|
47
|
+
docs design Print the design guide: tokens, themes, recipes, examples
|
|
48
|
+
pair Redeem a one-time pairing code into a stored context
|
|
49
|
+
context list List stored contexts (never prints a key)
|
|
50
|
+
context show [name] Show one context, the current one by default
|
|
51
|
+
context use <name> Make a context current
|
|
52
|
+
context remove <name> Delete a context and its stored key
|
|
53
|
+
init Write ${PROJECT_FILE} (add --starter app for a buildable app)
|
|
39
54
|
agent-pack Install the guide and the MCP wiring into a repository
|
|
40
55
|
discover Inventory an existing Node-RED installation offline
|
|
41
56
|
check Validate the project and the artifact offline
|
|
57
|
+
status Read the live generation and release of the home
|
|
42
58
|
publish Upload, finalize and activate the built artifact
|
|
43
59
|
activate Activate an already finalized digest at a new generation
|
|
44
60
|
rollback Alias of activate, for returning to a known-good digest
|
|
@@ -49,17 +65,28 @@ Commands
|
|
|
49
65
|
version Print the CLI version
|
|
50
66
|
|
|
51
67
|
Common options
|
|
68
|
+
--help, -h Print this guide without running the command
|
|
52
69
|
--json Print one machine-readable object on stdout
|
|
53
70
|
--project <dir> Start the ${PROJECT_FILE} search here (default: cwd)
|
|
71
|
+
--context <name> Use this stored context (status, publish, activate,
|
|
72
|
+
rollback, release, upload, init)
|
|
73
|
+
|
|
74
|
+
pair options
|
|
75
|
+
(code) Read from stdin: hidden prompt on a terminal, first
|
|
76
|
+
line of a pipe otherwise. Preferred: never echoed.
|
|
77
|
+
--code <code> Pass the code explicitly (lands in shell history)
|
|
78
|
+
--issuer <https url> Control plane to redeem at (default: ${DEFAULT_ISSUER})
|
|
79
|
+
--label <label> Key label shown to the owner (default: miakapp-cli@host)
|
|
80
|
+
--name <context> Context name (default: the paired home ID)
|
|
54
81
|
|
|
55
82
|
publish options
|
|
56
|
-
--expected-generation <n> Generation the pointer
|
|
83
|
+
--expected-generation <n> Generation the pointer holds (default: read live)
|
|
57
84
|
--generation <n> Generation to publish (default: expected + 1)
|
|
58
|
-
--release <name> Override component.release from ${PROJECT_FILE}
|
|
85
|
+
--release <name> Override app.release or component.release from ${PROJECT_FILE}
|
|
59
86
|
|
|
60
87
|
activate / rollback options
|
|
61
88
|
--sha256 <digest> Finalized artifact digest (required)
|
|
62
|
-
--expected-generation <n> Generation the pointer
|
|
89
|
+
--expected-generation <n> Generation the pointer holds (default: read live)
|
|
63
90
|
--generation <n> Generation to publish (default: expected + 1)
|
|
64
91
|
|
|
65
92
|
discover options
|
|
@@ -74,33 +101,52 @@ agent-pack options
|
|
|
74
101
|
--dir <path> Repository to install into (default: cwd)
|
|
75
102
|
|
|
76
103
|
init options
|
|
77
|
-
--home <homeId> Home ID
|
|
78
|
-
--control-plane <https url> Control-plane issuer (
|
|
79
|
-
--
|
|
104
|
+
--home <homeId> Home ID (default: from the selected context)
|
|
105
|
+
--control-plane <https url> Control-plane issuer (default: from the context)
|
|
106
|
+
--kind <app|component> Interface kind (default: app)
|
|
107
|
+
--artifact <path> Built artifact path (default: dist/app.js for app,
|
|
108
|
+
dist/component.js for component)
|
|
80
109
|
--release <name> Initial release name (default: 0.1.0)
|
|
110
|
+
--starter app Also write a self-contained app to edit: app/main.ts,
|
|
111
|
+
the app/miakapp.ts bridge, package.json (build and
|
|
112
|
+
check scripts), tsconfig.json and app/README.md.
|
|
113
|
+
Needs Bun >= 1.2.23 to build. Refuses, writing
|
|
114
|
+
nothing, if any of these files already exists.
|
|
115
|
+
Without it, init writes ${PROJECT_FILE} only.
|
|
116
|
+
|
|
117
|
+
Credentials, highest precedence first
|
|
118
|
+
1. --context <name> a context stored by miakapp pair
|
|
119
|
+
2. ${CONTEXT_VARIABLE}=<name> the same, from the environment
|
|
120
|
+
3. ${HOME_KEY_VARIABLE} a raw Home Key, for CI and compatibility
|
|
121
|
+
4. the stored context whose home and issuer match ${PROJECT_FILE}
|
|
122
|
+
(the current context wins a tie)
|
|
123
|
+
A stored context whose home or issuer differs from ${PROJECT_FILE} is refused,
|
|
124
|
+
so a key can never publish into another home. Keys are never printed.
|
|
81
125
|
|
|
82
126
|
Environment
|
|
83
|
-
|
|
84
|
-
publish, activate, rollback, release and upload. It is never
|
|
85
|
-
accepted as an argument and never printed.
|
|
127
|
+
MIAKAPP_CONFIG_DIR Absolute directory replacing ~/.miakapp
|
|
86
128
|
|
|
87
129
|
Exit codes
|
|
88
130
|
0 success 1 usage 2 project 3 artifact
|
|
89
131
|
4 authorization 5 contract 6 conflict 7 unknown outcome
|
|
90
132
|
`;
|
|
91
|
-
const GLOBAL_FLAGS = ['json'];
|
|
133
|
+
const GLOBAL_FLAGS = ['json', 'help'];
|
|
92
134
|
const GLOBAL_OPTIONS = ['project'];
|
|
93
135
|
/** Exported so the MCP surface can be proved to expose every option, and no other. */
|
|
94
136
|
export const COMMAND_OPTIONS = {
|
|
95
|
-
|
|
137
|
+
docs: [],
|
|
138
|
+
pair: ['code', 'issuer', 'label', 'name'],
|
|
139
|
+
context: [],
|
|
140
|
+
init: ['home', 'control-plane', 'artifact', 'release', 'context', 'kind', 'starter'],
|
|
96
141
|
'agent-pack': ['dir'],
|
|
97
142
|
discover: ['flows'],
|
|
98
143
|
check: [],
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
144
|
+
status: ['context'],
|
|
145
|
+
publish: ['expected-generation', 'generation', 'release', 'context'],
|
|
146
|
+
activate: ['sha256', 'expected-generation', 'generation', 'context'],
|
|
147
|
+
rollback: ['sha256', 'expected-generation', 'generation', 'context'],
|
|
148
|
+
release: ['context'],
|
|
149
|
+
upload: ['context'],
|
|
104
150
|
mcp: [],
|
|
105
151
|
help: [],
|
|
106
152
|
version: [],
|
|
@@ -132,6 +178,10 @@ export function parseArguments(argv) {
|
|
|
132
178
|
const positional = [];
|
|
133
179
|
for (let index = 1; index < argv.length; index += 1) {
|
|
134
180
|
const argument = argv[index];
|
|
181
|
+
if (argument === '-h') {
|
|
182
|
+
flags.add('help');
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
135
185
|
if (!argument.startsWith('--')) {
|
|
136
186
|
positional.push(argument);
|
|
137
187
|
continue;
|
|
@@ -163,7 +213,7 @@ export function parseArguments(argv) {
|
|
|
163
213
|
options.set(name, value);
|
|
164
214
|
index += 1;
|
|
165
215
|
}
|
|
166
|
-
return { command, options, flags, positional };
|
|
216
|
+
return { command: flags.has('help') ? 'help' : command, options, flags, positional };
|
|
167
217
|
}
|
|
168
218
|
function requiredOption(invocation, name) {
|
|
169
219
|
const value = invocation.options.get(name);
|
|
@@ -181,17 +231,34 @@ function generationOption(invocation, name) {
|
|
|
181
231
|
throw usageError(`--${name} is above the safe integer range`);
|
|
182
232
|
return value;
|
|
183
233
|
}
|
|
184
|
-
/**
|
|
185
|
-
function
|
|
186
|
-
const expectedGeneration =
|
|
234
|
+
/** Validated before any network use, so a typo never costs a request. */
|
|
235
|
+
function generationPlan(invocation) {
|
|
236
|
+
const expectedGeneration = invocation.options.has('expected-generation')
|
|
237
|
+
? generationOption(invocation, 'expected-generation')
|
|
238
|
+
: undefined;
|
|
187
239
|
const generation = invocation.options.has('generation')
|
|
188
240
|
? generationOption(invocation, 'generation')
|
|
189
|
-
:
|
|
190
|
-
if (generation
|
|
241
|
+
: undefined;
|
|
242
|
+
if (expectedGeneration !== undefined && generation !== undefined
|
|
243
|
+
&& generation <= expectedGeneration) {
|
|
191
244
|
throw usageError('--generation must be strictly above --expected-generation');
|
|
192
245
|
}
|
|
193
246
|
return { expectedGeneration, generation };
|
|
194
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Resolves the compare-and-set pair. Without `--expected-generation` the live
|
|
250
|
+
* generation is read first. That read takes no lock: the activation is still a
|
|
251
|
+
* compare-and-set, so a publication that lands in between fails with
|
|
252
|
+
* `conflict` rather than being overwritten.
|
|
253
|
+
*/
|
|
254
|
+
async function resolveGenerations(target, plan) {
|
|
255
|
+
const expectedGeneration = plan.expectedGeneration ?? (await readPointer(target)).generation;
|
|
256
|
+
const generation = plan.generation ?? expectedGeneration + 1;
|
|
257
|
+
if (generation <= expectedGeneration) {
|
|
258
|
+
throw usageError(`--generation must be strictly above the live generation ${expectedGeneration}`);
|
|
259
|
+
}
|
|
260
|
+
return { expectedGeneration, generation };
|
|
261
|
+
}
|
|
195
262
|
function digestOption(invocation, name) {
|
|
196
263
|
const value = requiredOption(invocation, name);
|
|
197
264
|
if (!isDigest(value)) {
|
|
@@ -199,13 +266,95 @@ function digestOption(invocation, name) {
|
|
|
199
266
|
}
|
|
200
267
|
return value;
|
|
201
268
|
}
|
|
202
|
-
function
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
269
|
+
function nonEmpty(value) {
|
|
270
|
+
return value === undefined || value === '' ? undefined : value;
|
|
271
|
+
}
|
|
272
|
+
function storeDirectory(host) {
|
|
273
|
+
return configDirectory((name) => host.env(name), host.homeDirectory?.() ?? homedir());
|
|
274
|
+
}
|
|
275
|
+
/** The context named for this invocation, by flag first and then environment. */
|
|
276
|
+
function explicitContext(host, invocation) {
|
|
277
|
+
const name = invocation.options.get('context') ?? nonEmpty(host.env(CONTEXT_VARIABLE));
|
|
278
|
+
if (name !== undefined && !isContextName(name))
|
|
279
|
+
contextNameError(name);
|
|
280
|
+
return name;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* The wrong-home guard. The project file says where a publication goes; a
|
|
284
|
+
* context says which home its key opens. When the two disagree the command
|
|
285
|
+
* stops, because the control plane would accept a valid key for home A
|
|
286
|
+
* publishing into home A, while the person meant home B.
|
|
287
|
+
*/
|
|
288
|
+
function requireSameHome(context, project, origin) {
|
|
289
|
+
if (context.homeId === project.homeId && context.issuer === project.issuer)
|
|
290
|
+
return;
|
|
291
|
+
throw authorizationError(`${origin} is for home ${context.homeId} at ${context.issuer}, but ${PROJECT_FILE} targets `
|
|
292
|
+
+ `home ${project.homeId} at ${project.issuer}`, 'Select the context paired with this home (miakapp context list), or pair it with '
|
|
293
|
+
+ 'miakapp pair. Nothing was sent.');
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Picks the Home Key for one invocation. Precedence, highest first:
|
|
297
|
+
*
|
|
298
|
+
* 1. `--context <name>`, then `MIAKAPP_CONTEXT`: an explicit stored context;
|
|
299
|
+
* 2. `MIAKAPP_HOME_KEY`: a raw key, for CI and compatibility;
|
|
300
|
+
* 3. the stored context paired with the project's home and issuer, the
|
|
301
|
+
* current context breaking a tie between several keys for that one home.
|
|
302
|
+
*
|
|
303
|
+
* A stored context is used only for the home it was paired with, and a raw
|
|
304
|
+
* key known to belong to another stored home is refused the same way.
|
|
305
|
+
*/
|
|
306
|
+
async function resolveCredential(host, invocation, project) {
|
|
307
|
+
const directory = storeDirectory(host);
|
|
308
|
+
const explicit = explicitContext(host, invocation);
|
|
309
|
+
if (explicit !== undefined) {
|
|
310
|
+
const { context, homeKey } = await readCredential(directory, explicit);
|
|
311
|
+
requireSameHome(context, project, `Context ${context.name}`);
|
|
312
|
+
return { homeKey, source: 'context', context };
|
|
207
313
|
}
|
|
208
|
-
|
|
314
|
+
const environmentKey = nonEmpty(host.env(HOME_KEY_VARIABLE));
|
|
315
|
+
if (environmentKey !== undefined) {
|
|
316
|
+
const keyId = homeKeyId(environmentKey);
|
|
317
|
+
let known = [];
|
|
318
|
+
try {
|
|
319
|
+
known = (await readContexts(directory)).contexts.values();
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
// The guard is best effort here: a CI runner has no store, and a broken
|
|
323
|
+
// one must not block a key the caller supplied explicitly.
|
|
324
|
+
}
|
|
325
|
+
for (const context of known) {
|
|
326
|
+
if (context.keyId === keyId) {
|
|
327
|
+
requireSameHome(context, project, `${HOME_KEY_VARIABLE} (paired as context ${context.name})`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return { homeKey: environmentKey, source: 'environment', context: undefined };
|
|
331
|
+
}
|
|
332
|
+
const state = await readContexts(directory);
|
|
333
|
+
const matching = [...state.contexts.values()]
|
|
334
|
+
.filter((context) => context.homeId === project.homeId && context.issuer === project.issuer)
|
|
335
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
|
336
|
+
const chosen = matching.find((context) => context.name === state.current) ?? matching[0];
|
|
337
|
+
if (chosen === undefined) {
|
|
338
|
+
const others = [...state.contexts.keys()];
|
|
339
|
+
throw authorizationError(`No credential for home ${project.homeId} at ${project.issuer}`, 'Pair this home: ask the owner to open '
|
|
340
|
+
+ `${PAIRING_PAGE}, choose it, confirm access and send you the code, then run `
|
|
341
|
+
+ `miakapp pair. ${HOME_KEY_VARIABLE} also works, for CI.`
|
|
342
|
+
+ (others.length === 0 ? '' : ` Stored contexts are for other homes: ${others.join(', ')}.`));
|
|
343
|
+
}
|
|
344
|
+
const { homeKey } = await readCredential(directory, chosen.name);
|
|
345
|
+
return { homeKey, source: 'context', context: chosen };
|
|
346
|
+
}
|
|
347
|
+
function credentialJson(credential) {
|
|
348
|
+
return {
|
|
349
|
+
credential_source: credential.source,
|
|
350
|
+
context: credential.context?.name ?? null,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function credentialFields(credential) {
|
|
354
|
+
return [[
|
|
355
|
+
'credential',
|
|
356
|
+
credential.context === undefined ? HOME_KEY_VARIABLE : `context ${credential.context.name}`,
|
|
357
|
+
]];
|
|
209
358
|
}
|
|
210
359
|
async function nodeFileSystem() {
|
|
211
360
|
const fs = await import('node:fs/promises');
|
|
@@ -257,30 +406,47 @@ async function loadArtifact(host, project) {
|
|
|
257
406
|
}
|
|
258
407
|
return prepareArtifact(await filesystem.read(project.artifactPath));
|
|
259
408
|
}
|
|
260
|
-
async function publicationTarget(host, project) {
|
|
261
|
-
const
|
|
409
|
+
async function publicationTarget(host, invocation, project) {
|
|
410
|
+
const credential = await resolveCredential(host, invocation, project);
|
|
262
411
|
const options = host.fetch === undefined ? {} : { fetch: host.fetch };
|
|
263
412
|
const discovery = await fetchDiscovery({ issuer: project.issuer, ...options });
|
|
264
|
-
const token = await exchangePublisherToken(discovery,
|
|
265
|
-
return {
|
|
413
|
+
const token = await exchangePublisherToken(discovery, credential.homeKey, options);
|
|
414
|
+
return {
|
|
415
|
+
target: { issuer: discovery.issuer, homeId: project.homeId, token: token.accessToken, ...options },
|
|
416
|
+
credential,
|
|
417
|
+
homeUrl: homeUrl(discovery, project.homeId),
|
|
418
|
+
};
|
|
266
419
|
}
|
|
267
420
|
function requirementFields(requires) {
|
|
268
421
|
return REQUIREMENT_KINDS.map((kind) => [`requires.${kind}`, requires[kind]]);
|
|
269
422
|
}
|
|
270
|
-
|
|
423
|
+
/**
|
|
424
|
+
* `home_url` is the link residents open — the trusted Miakapp shell for this
|
|
425
|
+
* home, from discovery — and the only link to hand a person. `url` stays the
|
|
426
|
+
* raw artifact the shell verifies and runs; it is printed as `artifact_url` so
|
|
427
|
+
* it is never mistaken for a page.
|
|
428
|
+
*/
|
|
429
|
+
function pointerResult(summary, pointer, credential, expectedGeneration, residentUrl) {
|
|
271
430
|
return {
|
|
272
431
|
summary,
|
|
273
432
|
fields: [
|
|
433
|
+
...credentialFields(credential),
|
|
434
|
+
...(expectedGeneration === undefined
|
|
435
|
+
? []
|
|
436
|
+
: [['expected_generation', expectedGeneration]]),
|
|
274
437
|
['home', pointer.homeId],
|
|
438
|
+
['home_url', residentUrl ?? 'not advertised by this control plane'],
|
|
439
|
+
['abi', pointer.abi],
|
|
275
440
|
['generation', pointer.generation],
|
|
276
441
|
['release', pointer.release],
|
|
277
442
|
['sha256', pointer.sha256],
|
|
278
443
|
['size', pointer.size],
|
|
279
|
-
['
|
|
444
|
+
['artifact_url', pointer.url],
|
|
280
445
|
...requirementFields(pointer.requires),
|
|
281
446
|
],
|
|
282
447
|
json: {
|
|
283
448
|
home_id: pointer.homeId,
|
|
449
|
+
home_url: residentUrl,
|
|
284
450
|
generation: pointer.generation,
|
|
285
451
|
release: pointer.release,
|
|
286
452
|
abi: pointer.abi,
|
|
@@ -288,10 +454,29 @@ function pointerResult(summary, pointer) {
|
|
|
288
454
|
sha256: pointer.sha256,
|
|
289
455
|
size: pointer.size,
|
|
290
456
|
requires: pointer.requires,
|
|
457
|
+
...(expectedGeneration === undefined ? {} : { expected_generation: expectedGeneration }),
|
|
458
|
+
...credentialJson(credential),
|
|
291
459
|
},
|
|
292
460
|
};
|
|
293
461
|
}
|
|
294
462
|
function projectTemplate(fields) {
|
|
463
|
+
if (fields.kind === 'app') {
|
|
464
|
+
return `schema: ${PROJECT_SCHEMA}
|
|
465
|
+
home: ${fields.home}
|
|
466
|
+
control_plane: ${fields.controlPlane}
|
|
467
|
+
|
|
468
|
+
# A whole-house application: one self-contained classic-script (IIFE) bundle
|
|
469
|
+
# that draws its own interface in Miakapp's isolated frame. Every path read and
|
|
470
|
+
# every function called must be declared; the coordinator still decides, per
|
|
471
|
+
# resident, what each one may see and do.
|
|
472
|
+
app:
|
|
473
|
+
artifact: ${fields.artifact}
|
|
474
|
+
release: ${fields.release}
|
|
475
|
+
requires:
|
|
476
|
+
state_read: []
|
|
477
|
+
call: []
|
|
478
|
+
`;
|
|
479
|
+
}
|
|
295
480
|
return `schema: ${PROJECT_SCHEMA}
|
|
296
481
|
home: ${fields.home}
|
|
297
482
|
control_plane: ${fields.controlPlane}
|
|
@@ -311,26 +496,127 @@ async function runInit(host, invocation) {
|
|
|
311
496
|
const filesystem = await files(host);
|
|
312
497
|
const root = invocation.options.get('project') ?? host.cwd();
|
|
313
498
|
const path = `${root}/${PROJECT_FILE}`;
|
|
314
|
-
if (await filesystem.exists(path)) {
|
|
315
|
-
throw projectError(`${path} already exists`, 'The CLI never overwrites a project file; edit it or remove it first.');
|
|
316
|
-
}
|
|
317
499
|
const release = invocation.options.get('release') ?? '0.1.0';
|
|
318
500
|
if (!isRelease(release)) {
|
|
319
501
|
throw usageError('--release must be 1..64 UTF-8 bytes without control characters');
|
|
320
502
|
}
|
|
503
|
+
const starter = invocation.options.get('starter');
|
|
504
|
+
if (starter !== undefined && !isStarter(starter)) {
|
|
505
|
+
throw usageError(`--starter must be ${STARTERS.join(' or ')}, received ${starter}`);
|
|
506
|
+
}
|
|
507
|
+
const kind = invocation.options.get('kind') ?? 'app';
|
|
508
|
+
if (kind !== 'app' && kind !== 'component') {
|
|
509
|
+
throw usageError('--kind must be app (whole-house application, default) or component');
|
|
510
|
+
}
|
|
511
|
+
if (starter !== undefined && kind !== 'app') {
|
|
512
|
+
throw usageError('--starter app writes a whole-house application; it cannot be combined with --kind component');
|
|
513
|
+
}
|
|
514
|
+
const artifact = invocation.options.get('artifact') ?? (kind === 'app' ? 'dist/app.js' : 'dist/component.js');
|
|
515
|
+
if (starter !== undefined)
|
|
516
|
+
validateStarterArtifact(artifact);
|
|
517
|
+
// Every collision is found before anything is written: a refused init leaves
|
|
518
|
+
// the directory exactly as it was, never half a project.
|
|
519
|
+
const planned = [PROJECT_FILE, ...(starter === undefined ? [] : STARTER_FILES)];
|
|
520
|
+
const collisions = [];
|
|
521
|
+
for (const relative of planned) {
|
|
522
|
+
if (await filesystem.exists(`${root}/${relative}`))
|
|
523
|
+
collisions.push(relative);
|
|
524
|
+
}
|
|
525
|
+
if (collisions.length > 0) {
|
|
526
|
+
throw projectError(`${collisions.map((relative) => `${root}/${relative}`).join(', ')} already `
|
|
527
|
+
+ `${collisions.length === 1 ? 'exists' : 'exist'}; nothing was written`, 'The CLI never overwrites a file. Run init in a new directory, or move these files aside.');
|
|
528
|
+
}
|
|
529
|
+
const target = await initTarget(host, invocation);
|
|
321
530
|
const source = projectTemplate({
|
|
322
|
-
home:
|
|
323
|
-
controlPlane:
|
|
324
|
-
artifact
|
|
531
|
+
home: target.home,
|
|
532
|
+
controlPlane: target.controlPlane,
|
|
533
|
+
artifact,
|
|
325
534
|
release,
|
|
535
|
+
kind,
|
|
326
536
|
});
|
|
327
537
|
// Parsed before it is written, so init can never emit a file check rejects.
|
|
328
538
|
parseProject(root, source);
|
|
539
|
+
const written = [];
|
|
540
|
+
if (starter !== undefined) {
|
|
541
|
+
const starterSources = await starterFiles({ home: target.home, artifact });
|
|
542
|
+
try {
|
|
543
|
+
await filesystem.makeDirectory(`${root}/app`);
|
|
544
|
+
for (const [relative, bytes] of starterSources) {
|
|
545
|
+
await filesystem.write(`${root}/${relative}`, bytes);
|
|
546
|
+
written.push(relative);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
const reason = error instanceof Error ? error.message : 'unknown failure';
|
|
551
|
+
throw projectError(`The starter could not be written (${reason}); ${PROJECT_FILE} was not written`
|
|
552
|
+
+ (written.length === 0 ? '' : ` and these files were: ${written.join(', ')}`), 'Remove the files listed, fix the directory, and run init again.');
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// The manifest goes last: a directory holding it always holds the whole starter.
|
|
329
556
|
await filesystem.write(path, new TextEncoder().encode(source));
|
|
557
|
+
written.push(PROJECT_FILE);
|
|
330
558
|
return {
|
|
331
|
-
summary:
|
|
332
|
-
|
|
333
|
-
|
|
559
|
+
summary: starter === undefined
|
|
560
|
+
? `Wrote ${path}`
|
|
561
|
+
: `Wrote an app starter in ${root}: inventory the house, declare app.requires, then build`,
|
|
562
|
+
fields: [
|
|
563
|
+
['project', path],
|
|
564
|
+
['home', target.home],
|
|
565
|
+
['control_plane', target.controlPlane],
|
|
566
|
+
...(target.context === undefined ? [] : [['context', target.context]]),
|
|
567
|
+
...(starter === undefined ? [] : [
|
|
568
|
+
['starter', starter],
|
|
569
|
+
['files', written],
|
|
570
|
+
['next', 'read app/README.md; bun run build; bun run check'],
|
|
571
|
+
]),
|
|
572
|
+
],
|
|
573
|
+
json: {
|
|
574
|
+
project: path,
|
|
575
|
+
schema: PROJECT_SCHEMA,
|
|
576
|
+
kind,
|
|
577
|
+
home_id: target.home,
|
|
578
|
+
control_plane: target.controlPlane,
|
|
579
|
+
context: target.context ?? null,
|
|
580
|
+
artifact,
|
|
581
|
+
release,
|
|
582
|
+
starter: starter ?? null,
|
|
583
|
+
files: written,
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* `--home` and `--control-plane` default to the selected context — the one
|
|
589
|
+
* named by `--context` or `MIAKAPP_CONTEXT`, else the current one — so the step
|
|
590
|
+
* after `miakapp pair` needs no identifier typed by hand. An explicit value
|
|
591
|
+
* that contradicts an explicitly selected context is refused rather than
|
|
592
|
+
* silently preferred.
|
|
593
|
+
*/
|
|
594
|
+
async function initTarget(host, invocation) {
|
|
595
|
+
const home = invocation.options.get('home');
|
|
596
|
+
const controlPlane = invocation.options.get('control-plane');
|
|
597
|
+
const explicit = explicitContext(host, invocation);
|
|
598
|
+
if (explicit === undefined && home !== undefined && controlPlane !== undefined) {
|
|
599
|
+
return { home, controlPlane, context: undefined };
|
|
600
|
+
}
|
|
601
|
+
const state = await readContexts(storeDirectory(host));
|
|
602
|
+
const name = explicit ?? state.current ?? undefined;
|
|
603
|
+
const context = name === undefined ? undefined : state.contexts.get(name);
|
|
604
|
+
if (explicit !== undefined && context === undefined) {
|
|
605
|
+
throw usageError(`No context named ${explicit}`, 'Run miakapp context list.');
|
|
606
|
+
}
|
|
607
|
+
if (context === undefined) {
|
|
608
|
+
throw usageError(`--${home === undefined ? 'home' : 'control-plane'} is required when no context is selected`, 'Pair the home first with miakapp pair, or pass both --home and --control-plane.');
|
|
609
|
+
}
|
|
610
|
+
if (explicit !== undefined
|
|
611
|
+
&& ((home !== undefined && home !== context.homeId)
|
|
612
|
+
|| (controlPlane !== undefined && controlPlane !== context.issuer))) {
|
|
613
|
+
throw usageError(`Context ${context.name} is for home ${context.homeId} at ${context.issuer}, which `
|
|
614
|
+
+ 'contradicts --home or --control-plane');
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
home: home ?? context.homeId,
|
|
618
|
+
controlPlane: controlPlane ?? context.issuer,
|
|
619
|
+
context: context.name,
|
|
334
620
|
};
|
|
335
621
|
}
|
|
336
622
|
/**
|
|
@@ -347,6 +633,43 @@ export async function guideAssetPath() {
|
|
|
347
633
|
const { fileURLToPath } = await import('node:url');
|
|
348
634
|
return fileURLToPath(new URL('../assets/agent-guide.md', import.meta.url));
|
|
349
635
|
}
|
|
636
|
+
/** Directory of the design files (`tokens.css`, `gallery.html`…), resolved like the guide. */
|
|
637
|
+
export async function designAssetDirectory() {
|
|
638
|
+
const { fileURLToPath } = await import('node:url');
|
|
639
|
+
return fileURLToPath(new URL('../assets/design/', import.meta.url));
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Gives an agent its complete starting contract without requiring a repository,
|
|
643
|
+
* a network request or an MCP client. The onboarding page can therefore hand a
|
|
644
|
+
* person one stable command and the installed CLI remains the source of truth.
|
|
645
|
+
*/
|
|
646
|
+
async function runDocs(host, invocation) {
|
|
647
|
+
const topic = invocation.positional.length === 1 ? invocation.positional[0] : undefined;
|
|
648
|
+
if (topic !== 'start' && topic !== 'design') {
|
|
649
|
+
throw usageError('docs requires the topic start or design', 'Run miakapp docs start to print the complete agent guide, miakapp docs design for the design guide.');
|
|
650
|
+
}
|
|
651
|
+
const directory = topic === 'design' ? await designAssetDirectory() : undefined;
|
|
652
|
+
const path = directory === undefined ? await guideAssetPath() : `${directory}guide.md`;
|
|
653
|
+
let guide;
|
|
654
|
+
try {
|
|
655
|
+
const filesystem = await files(host);
|
|
656
|
+
guide = new TextDecoder('utf-8', { fatal: true }).decode(await filesystem.read(path));
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
throw projectError(`The packaged guide is missing or unreadable at ${path}`, 'Reinstall @miakapp/cli: the guide is bundled with the package and opens no network connection.');
|
|
660
|
+
}
|
|
661
|
+
if (!guide.endsWith('\n'))
|
|
662
|
+
guide = `${guide}\n`;
|
|
663
|
+
if (directory === undefined) {
|
|
664
|
+
return { summary: 'Miakapp agent guide', fields: [], json: { topic, guide }, text: guide };
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
summary: 'Miakapp design guide',
|
|
668
|
+
fields: [],
|
|
669
|
+
json: { topic, guide, directory },
|
|
670
|
+
text: `${guide}\nDesign directory: ${directory}\n`,
|
|
671
|
+
};
|
|
672
|
+
}
|
|
350
673
|
/**
|
|
351
674
|
* Installs the agent pack. Like `discover`, it loads no project file: the
|
|
352
675
|
* repository it prepares is usually one that has no V4 project yet.
|
|
@@ -455,6 +778,7 @@ async function runCheck(host, invocation) {
|
|
|
455
778
|
fields: [
|
|
456
779
|
['home', project.homeId],
|
|
457
780
|
['control_plane', project.issuer],
|
|
781
|
+
['abi', project.abi],
|
|
458
782
|
['release', project.release],
|
|
459
783
|
['artifact', project.artifactPath],
|
|
460
784
|
['sha256', artifact.sha256],
|
|
@@ -465,6 +789,7 @@ async function runCheck(host, invocation) {
|
|
|
465
789
|
json: {
|
|
466
790
|
home_id: project.homeId,
|
|
467
791
|
control_plane: project.issuer,
|
|
792
|
+
abi: project.abi,
|
|
468
793
|
release: project.release,
|
|
469
794
|
artifact: project.artifactPath,
|
|
470
795
|
sha256: artifact.sha256,
|
|
@@ -475,35 +800,38 @@ async function runCheck(host, invocation) {
|
|
|
475
800
|
};
|
|
476
801
|
}
|
|
477
802
|
async function runPublish(host, invocation) {
|
|
478
|
-
const
|
|
803
|
+
const plan = generationPlan(invocation);
|
|
479
804
|
const project = await loadProject(host, invocation);
|
|
480
805
|
const release = invocation.options.get('release') ?? project.release;
|
|
481
806
|
if (!isRelease(release)) {
|
|
482
807
|
throw usageError('--release must be 1..64 UTF-8 bytes without control characters');
|
|
483
808
|
}
|
|
484
809
|
const artifact = await loadArtifact(host, project);
|
|
485
|
-
const target = await publicationTarget(host, project);
|
|
810
|
+
const { target, credential, homeUrl: residentUrl } = await publicationTarget(host, invocation, project);
|
|
811
|
+
const { expectedGeneration, generation } = await resolveGenerations(target, plan);
|
|
486
812
|
const { pointer } = await publish(target, artifact, {
|
|
487
813
|
release,
|
|
814
|
+
abi: project.abi,
|
|
488
815
|
requires: project.requires,
|
|
489
816
|
expectedGeneration,
|
|
490
817
|
generation,
|
|
491
818
|
});
|
|
492
|
-
return pointerResult(`Published ${release} as generation ${pointer.generation}`, pointer);
|
|
819
|
+
return pointerResult(`Published ${release} as generation ${pointer.generation}`, pointer, credential, expectedGeneration, residentUrl);
|
|
493
820
|
}
|
|
494
821
|
async function runActivate(host, invocation) {
|
|
495
822
|
const sha256 = digestOption(invocation, 'sha256');
|
|
496
|
-
const
|
|
823
|
+
const plan = generationPlan(invocation);
|
|
497
824
|
const project = await loadProject(host, invocation);
|
|
498
|
-
const target = await publicationTarget(host, project);
|
|
825
|
+
const { target, credential, homeUrl: residentUrl } = await publicationTarget(host, invocation, project);
|
|
499
826
|
// Activation is checked against a readable finalized record first, so a typo
|
|
500
827
|
// in a digest fails as an artifact error instead of spending a CAS attempt.
|
|
501
828
|
const existing = await readRelease(target, sha256);
|
|
502
829
|
if (existing === undefined) {
|
|
503
830
|
throw artifactError(`No finalized release for ${sha256}`, 'Activate only a digest this home has already published.');
|
|
504
831
|
}
|
|
832
|
+
const { expectedGeneration, generation } = await resolveGenerations(target, plan);
|
|
505
833
|
const pointer = await activateRelease(target, { sha256, expectedGeneration, generation });
|
|
506
|
-
return pointerResult(`Activated ${existing.release} as generation ${pointer.generation}`, pointer);
|
|
834
|
+
return pointerResult(`Activated ${existing.release} as generation ${pointer.generation}`, pointer, credential, expectedGeneration, residentUrl);
|
|
507
835
|
}
|
|
508
836
|
async function runRelease(host, invocation) {
|
|
509
837
|
const sha256 = invocation.positional[0];
|
|
@@ -513,7 +841,7 @@ async function runRelease(host, invocation) {
|
|
|
513
841
|
throw usageError('The release digest must be 43 base64url characters');
|
|
514
842
|
}
|
|
515
843
|
const project = await loadProject(host, invocation);
|
|
516
|
-
const target = await publicationTarget(host, project);
|
|
844
|
+
const { target } = await publicationTarget(host, invocation, project);
|
|
517
845
|
const record = await readRelease(target, sha256);
|
|
518
846
|
if (record === undefined) {
|
|
519
847
|
throw artifactError(`No finalized release for ${sha256}`);
|
|
@@ -542,7 +870,7 @@ async function runUpload(host, invocation) {
|
|
|
542
870
|
if (uploadId === undefined)
|
|
543
871
|
throw usageError('upload requires one uploadId argument');
|
|
544
872
|
const project = await loadProject(host, invocation);
|
|
545
|
-
const target = await publicationTarget(host, project);
|
|
873
|
+
const { target } = await publicationTarget(host, invocation, project);
|
|
546
874
|
const state = await readUpload(target, uploadId);
|
|
547
875
|
return {
|
|
548
876
|
summary: `Upload ${state.uploadId} is ${state.status}`,
|
|
@@ -566,6 +894,281 @@ async function runUpload(host, invocation) {
|
|
|
566
894
|
},
|
|
567
895
|
};
|
|
568
896
|
}
|
|
897
|
+
async function runStatus(host, invocation) {
|
|
898
|
+
const project = await loadProject(host, invocation);
|
|
899
|
+
const { target, credential, homeUrl: residentUrl } = await publicationTarget(host, invocation, project);
|
|
900
|
+
const { generation, pointer } = await readPointer(target);
|
|
901
|
+
if (pointer === null) {
|
|
902
|
+
return {
|
|
903
|
+
summary: `${project.homeId} has never activated an interface (generation 0)`,
|
|
904
|
+
fields: [
|
|
905
|
+
['home', project.homeId],
|
|
906
|
+
['home_url', residentUrl ?? 'not advertised by this control plane'],
|
|
907
|
+
['generation', 0],
|
|
908
|
+
...credentialFields(credential),
|
|
909
|
+
],
|
|
910
|
+
json: {
|
|
911
|
+
home_id: project.homeId,
|
|
912
|
+
home_url: residentUrl,
|
|
913
|
+
generation,
|
|
914
|
+
active: false,
|
|
915
|
+
...credentialJson(credential),
|
|
916
|
+
},
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
const result = pointerResult(`${project.homeId} runs ${pointer.release} at generation ${generation}`, pointer, credential, undefined, residentUrl);
|
|
920
|
+
return { ...result, json: { ...result.json, active: true } };
|
|
921
|
+
}
|
|
922
|
+
function defaultLabel(host) {
|
|
923
|
+
const machine = (host.hostname?.() ?? hostname()).replace(/[^A-Za-z0-9._-]/g, '-');
|
|
924
|
+
return `miakapp-cli@${machine === '' ? 'unknown' : machine}`.slice(0, 64);
|
|
925
|
+
}
|
|
926
|
+
/** A free context name: the base itself, else base-2, base-3, ... */
|
|
927
|
+
function freeContextName(taken, base) {
|
|
928
|
+
if (!taken.has(base))
|
|
929
|
+
return base;
|
|
930
|
+
for (let suffix = 2;; suffix += 1) {
|
|
931
|
+
const candidate = `${base.slice(0, 62 - String(suffix).length)}-${suffix}`;
|
|
932
|
+
if (!taken.has(candidate))
|
|
933
|
+
return candidate;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
async function pairingCode(host, invocation) {
|
|
937
|
+
const explicit = invocation.options.get('code');
|
|
938
|
+
if (explicit !== undefined)
|
|
939
|
+
return explicit;
|
|
940
|
+
if (host.readSecret === undefined) {
|
|
941
|
+
throw usageError('pair needs the one-time code', 'Pipe it on stdin (printf %s "$CODE" | miakapp pair) or run miakapp pair in a terminal to '
|
|
942
|
+
+ 'be prompted without echo.');
|
|
943
|
+
}
|
|
944
|
+
return await host.readSecret('Pairing code (hidden): ');
|
|
945
|
+
}
|
|
946
|
+
/**
|
|
947
|
+
* Trades a one-time pairing code for a fresh Home Key and stores it as a new
|
|
948
|
+
* context. Every existing context is kept. The new context becomes current.
|
|
949
|
+
*
|
|
950
|
+
* Everything that can fail without spending the code — the issuer, the label,
|
|
951
|
+
* the name, the readability of the store and the issuer's discovery document —
|
|
952
|
+
* is checked before the code is sent, because a code works exactly once.
|
|
953
|
+
*/
|
|
954
|
+
async function runPair(host, invocation) {
|
|
955
|
+
const issuer = pairingIssuer(invocation.options.get('issuer') ?? DEFAULT_ISSUER);
|
|
956
|
+
const label = validateLabel(invocation.options.get('label') ?? defaultLabel(host));
|
|
957
|
+
const requestedName = invocation.options.get('name');
|
|
958
|
+
if (requestedName !== undefined && !isContextName(requestedName))
|
|
959
|
+
contextNameError(requestedName);
|
|
960
|
+
const directory = storeDirectory(host);
|
|
961
|
+
const before = await readContexts(directory);
|
|
962
|
+
if (requestedName !== undefined && before.contexts.has(requestedName)) {
|
|
963
|
+
throw usageError(`A context named ${requestedName} already exists`, `Choose another --name, or remove it first with miakapp context remove ${requestedName}. `
|
|
964
|
+
+ 'The code was not used.');
|
|
965
|
+
}
|
|
966
|
+
const code = normalizeCode(await pairingCode(host, invocation));
|
|
967
|
+
const options = host.fetch === undefined ? {} : { fetch: host.fetch };
|
|
968
|
+
let discovery;
|
|
969
|
+
try {
|
|
970
|
+
discovery = await fetchDiscovery({ issuer, ...options });
|
|
971
|
+
}
|
|
972
|
+
catch (error) {
|
|
973
|
+
const reason = error instanceof Error ? error.message : 'unknown failure';
|
|
974
|
+
throw contractError(`${issuer} did not prove it is a Miakapp control plane: ${reason}`, 'The code was not sent and is still valid until it expires. Check --issuer and the '
|
|
975
|
+
+ 'network, then run pair again with the same code.');
|
|
976
|
+
}
|
|
977
|
+
const paired = await redeemPairingCode({ issuer, code, label, ...options });
|
|
978
|
+
const createdAt = new Date().toISOString();
|
|
979
|
+
let stored;
|
|
980
|
+
try {
|
|
981
|
+
stored = await mutateContexts(directory, (state) => {
|
|
982
|
+
const previous = [...state.contexts.values()]
|
|
983
|
+
.filter((context) => context.homeId === paired.homeId && context.issuer === paired.issuer);
|
|
984
|
+
const name = freeContextName(state.contexts, requestedName ?? paired.homeId);
|
|
985
|
+
state.contexts.set(name, {
|
|
986
|
+
name,
|
|
987
|
+
issuer: paired.issuer,
|
|
988
|
+
homeId: paired.homeId,
|
|
989
|
+
keyId: paired.keyId,
|
|
990
|
+
label,
|
|
991
|
+
createdAt,
|
|
992
|
+
});
|
|
993
|
+
state.keys.set(name, paired.homeKey);
|
|
994
|
+
state.current = name;
|
|
995
|
+
return { name, previous };
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
catch (error) {
|
|
999
|
+
const reason = error instanceof Error ? error.message : 'unknown failure';
|
|
1000
|
+
throw authorizationError(`Home Key ${paired.keyId} for ${paired.homeId} was issued but could not be stored in `
|
|
1001
|
+
+ `${directory}: ${reason}`, `Ask the owner to revoke the key labelled ${JSON.stringify(label)}, fix the directory, then pair again.`);
|
|
1002
|
+
}
|
|
1003
|
+
// The key is stored; proving it can obtain a publication token tells the
|
|
1004
|
+
// agent now, rather than at its first publish, whether the grant is usable.
|
|
1005
|
+
let publishAccess = 'verified';
|
|
1006
|
+
let publishAccessDetail;
|
|
1007
|
+
try {
|
|
1008
|
+
await exchangePublisherToken(discovery, paired.homeKey, options);
|
|
1009
|
+
}
|
|
1010
|
+
catch (error) {
|
|
1011
|
+
publishAccess = 'refused';
|
|
1012
|
+
publishAccessDetail = error instanceof Error ? error.message : 'unknown failure';
|
|
1013
|
+
}
|
|
1014
|
+
const previous = stored.previous.map((context) => context.name);
|
|
1015
|
+
return {
|
|
1016
|
+
summary: `Paired ${paired.homeId} as context ${stored.name}`
|
|
1017
|
+
+ (publishAccess === 'verified' ? '' : ' (the key could not obtain a publication token)'),
|
|
1018
|
+
fields: [
|
|
1019
|
+
['context', stored.name],
|
|
1020
|
+
['home', paired.homeId],
|
|
1021
|
+
['issuer', paired.issuer],
|
|
1022
|
+
['key_id', paired.keyId],
|
|
1023
|
+
['label', label],
|
|
1024
|
+
['current', 'yes'],
|
|
1025
|
+
['publish_access', publishAccessDetail === undefined ? publishAccess : `${publishAccess}: ${publishAccessDetail}`],
|
|
1026
|
+
['config', directory],
|
|
1027
|
+
...(previous.length === 0 ? [] : [['earlier_contexts_for_this_home', previous]]),
|
|
1028
|
+
],
|
|
1029
|
+
json: {
|
|
1030
|
+
context: stored.name,
|
|
1031
|
+
home_id: paired.homeId,
|
|
1032
|
+
issuer: paired.issuer,
|
|
1033
|
+
key_id: paired.keyId,
|
|
1034
|
+
label,
|
|
1035
|
+
current: true,
|
|
1036
|
+
publish_access: publishAccess,
|
|
1037
|
+
...(publishAccessDetail === undefined ? {} : { publish_access_detail: publishAccessDetail }),
|
|
1038
|
+
config_directory: directory,
|
|
1039
|
+
earlier_contexts_for_this_home: previous,
|
|
1040
|
+
},
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
function contextJson(context, current, credential) {
|
|
1044
|
+
return {
|
|
1045
|
+
name: context.name,
|
|
1046
|
+
home_id: context.homeId,
|
|
1047
|
+
issuer: context.issuer,
|
|
1048
|
+
key_id: context.keyId,
|
|
1049
|
+
label: context.label,
|
|
1050
|
+
created_at: context.createdAt,
|
|
1051
|
+
current: context.name === current,
|
|
1052
|
+
credential: credential ?? 'missing',
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* `context list | show [name] | use <name> | remove <name>`.
|
|
1057
|
+
*
|
|
1058
|
+
* Nothing here prints a Home Key: `credential` reports only whether the stored
|
|
1059
|
+
* key is present and is the key the context was paired with.
|
|
1060
|
+
*/
|
|
1061
|
+
async function runContext(host, invocation) {
|
|
1062
|
+
const [action, name, extra] = invocation.positional;
|
|
1063
|
+
const directory = storeDirectory(host);
|
|
1064
|
+
if (extra !== undefined)
|
|
1065
|
+
throw usageError('context takes at most one name');
|
|
1066
|
+
const named = (verb) => {
|
|
1067
|
+
if (name === undefined)
|
|
1068
|
+
throw usageError(`context ${verb} requires a context name`);
|
|
1069
|
+
if (!isContextName(name))
|
|
1070
|
+
contextNameError(name);
|
|
1071
|
+
return name;
|
|
1072
|
+
};
|
|
1073
|
+
switch (action) {
|
|
1074
|
+
case 'list': {
|
|
1075
|
+
if (name !== undefined)
|
|
1076
|
+
throw usageError('context list takes no name');
|
|
1077
|
+
const state = await readContexts(directory);
|
|
1078
|
+
const contexts = [...state.contexts.values()];
|
|
1079
|
+
return {
|
|
1080
|
+
summary: contexts.length === 0
|
|
1081
|
+
? `No context in ${directory}. Run miakapp pair to add one.`
|
|
1082
|
+
: `${counted(contexts.length, 'context', 'contexts')} in ${directory}`,
|
|
1083
|
+
fields: contexts.map((context) => [
|
|
1084
|
+
`${context.name === state.current ? '* ' : ''}${context.name}`,
|
|
1085
|
+
`home ${context.homeId} at ${context.issuer}, key ${context.keyId} `
|
|
1086
|
+
+ `(${state.credentials.get(context.name) ?? 'missing'})`,
|
|
1087
|
+
]),
|
|
1088
|
+
json: {
|
|
1089
|
+
config_directory: directory,
|
|
1090
|
+
current_context: state.current,
|
|
1091
|
+
contexts: contexts.map((context) => contextJson(context, state.current, state.credentials.get(context.name))),
|
|
1092
|
+
},
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
case 'show': {
|
|
1096
|
+
const state = await readContexts(directory);
|
|
1097
|
+
const selected = name ?? state.current ?? undefined;
|
|
1098
|
+
if (selected === undefined) {
|
|
1099
|
+
throw usageError('No current context', 'Name one: miakapp context show <name>, or pair with miakapp pair.');
|
|
1100
|
+
}
|
|
1101
|
+
if (!isContextName(selected))
|
|
1102
|
+
contextNameError(selected);
|
|
1103
|
+
const context = state.contexts.get(selected);
|
|
1104
|
+
if (context === undefined) {
|
|
1105
|
+
throw usageError(`No context named ${selected}`, 'Run miakapp context list.');
|
|
1106
|
+
}
|
|
1107
|
+
const json = contextJson(context, state.current, state.credentials.get(context.name));
|
|
1108
|
+
return {
|
|
1109
|
+
summary: `Context ${context.name}${json['current'] === true ? ' (current)' : ''}`,
|
|
1110
|
+
fields: [
|
|
1111
|
+
['home', context.homeId],
|
|
1112
|
+
['issuer', context.issuer],
|
|
1113
|
+
['key_id', context.keyId],
|
|
1114
|
+
['label', context.label],
|
|
1115
|
+
['created_at', context.createdAt],
|
|
1116
|
+
['credential', `${String(json['credential'])} (never printed)`],
|
|
1117
|
+
['config', directory],
|
|
1118
|
+
],
|
|
1119
|
+
json: { ...json, config_directory: directory },
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
case 'use': {
|
|
1123
|
+
const selected = named('use');
|
|
1124
|
+
const context = await mutateContexts(directory, (state) => {
|
|
1125
|
+
const found = state.contexts.get(selected);
|
|
1126
|
+
if (found === undefined) {
|
|
1127
|
+
throw usageError(`No context named ${selected}`, 'Run miakapp context list.');
|
|
1128
|
+
}
|
|
1129
|
+
state.current = selected;
|
|
1130
|
+
return found;
|
|
1131
|
+
});
|
|
1132
|
+
return {
|
|
1133
|
+
summary: `Current context is now ${selected}`,
|
|
1134
|
+
fields: [['home', context.homeId], ['issuer', context.issuer]],
|
|
1135
|
+
json: { current_context: selected, home_id: context.homeId, issuer: context.issuer },
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
case 'remove': {
|
|
1139
|
+
const selected = named('remove');
|
|
1140
|
+
const result = await mutateContexts(directory, (state) => {
|
|
1141
|
+
const found = state.contexts.get(selected);
|
|
1142
|
+
if (found === undefined) {
|
|
1143
|
+
throw usageError(`No context named ${selected}`, 'Run miakapp context list.');
|
|
1144
|
+
}
|
|
1145
|
+
state.contexts.delete(selected);
|
|
1146
|
+
state.keys.delete(selected);
|
|
1147
|
+
const wasCurrent = state.current === selected;
|
|
1148
|
+
if (wasCurrent)
|
|
1149
|
+
state.current = null;
|
|
1150
|
+
return { context: found, wasCurrent };
|
|
1151
|
+
});
|
|
1152
|
+
return {
|
|
1153
|
+
summary: `Removed context ${selected} and its stored key`,
|
|
1154
|
+
fields: [
|
|
1155
|
+
['home', result.context.homeId],
|
|
1156
|
+
['key_id', result.context.keyId],
|
|
1157
|
+
['still_valid_on_server', 'yes: ask the owner to revoke it if it is no longer needed'],
|
|
1158
|
+
],
|
|
1159
|
+
json: {
|
|
1160
|
+
removed: selected,
|
|
1161
|
+
home_id: result.context.homeId,
|
|
1162
|
+
key_id: result.context.keyId,
|
|
1163
|
+
was_current: result.wasCurrent,
|
|
1164
|
+
revoked: false,
|
|
1165
|
+
},
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
default:
|
|
1169
|
+
throw usageError(action === undefined ? 'context requires an action' : `Unknown context action: ${action}`, 'Use context list, context show [name], context use <name> or context remove <name>.');
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
569
1172
|
/**
|
|
570
1173
|
* Runs one parsed invocation.
|
|
571
1174
|
*
|
|
@@ -574,6 +1177,14 @@ async function runUpload(host, invocation) {
|
|
|
574
1177
|
*/
|
|
575
1178
|
export async function dispatch(host, invocation) {
|
|
576
1179
|
switch (invocation.command) {
|
|
1180
|
+
case 'docs':
|
|
1181
|
+
return await runDocs(host, invocation);
|
|
1182
|
+
case 'pair':
|
|
1183
|
+
return await runPair(host, invocation);
|
|
1184
|
+
case 'context':
|
|
1185
|
+
return await runContext(host, invocation);
|
|
1186
|
+
case 'status':
|
|
1187
|
+
return await runStatus(host, invocation);
|
|
577
1188
|
case 'init':
|
|
578
1189
|
return await runInit(host, invocation);
|
|
579
1190
|
case 'agent-pack':
|
|
@@ -596,6 +1207,8 @@ export async function dispatch(host, invocation) {
|
|
|
596
1207
|
}
|
|
597
1208
|
}
|
|
598
1209
|
function renderText(result) {
|
|
1210
|
+
if (result.text !== undefined)
|
|
1211
|
+
return result.text;
|
|
599
1212
|
const lines = [result.summary];
|
|
600
1213
|
for (const [key, value] of result.fields) {
|
|
601
1214
|
lines.push(` ${key}: ${Array.isArray(value) ? `[${value.join(', ')}]` : String(value)}`);
|