@miakapp/cli 4.0.0-alpha.5 → 4.0.0-alpha.7

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.
Files changed (45) hide show
  1. package/README.md +106 -32
  2. package/assets/agent-guide.md +193 -22
  3. package/bin/miakapp.js +5 -1
  4. package/dist/agent-pack.d.ts.map +1 -1
  5. package/dist/agent-pack.js +12 -5
  6. package/dist/agent-pack.js.map +1 -1
  7. package/dist/contexts.d.ts +59 -0
  8. package/dist/contexts.d.ts.map +1 -0
  9. package/dist/contexts.js +388 -0
  10. package/dist/contexts.js.map +1 -0
  11. package/dist/control-plane.d.ts +14 -0
  12. package/dist/control-plane.d.ts.map +1 -1
  13. package/dist/control-plane.js +33 -1
  14. package/dist/control-plane.js.map +1 -1
  15. package/dist/internal/names.d.ts +5 -0
  16. package/dist/internal/names.d.ts.map +1 -1
  17. package/dist/internal/names.js +6 -0
  18. package/dist/internal/names.js.map +1 -1
  19. package/dist/main.d.ts +16 -3
  20. package/dist/main.d.ts.map +1 -1
  21. package/dist/main.js +567 -54
  22. package/dist/main.js.map +1 -1
  23. package/dist/mcp.d.ts +7 -1
  24. package/dist/mcp.d.ts.map +1 -1
  25. package/dist/mcp.js +167 -26
  26. package/dist/mcp.js.map +1 -1
  27. package/dist/pairing.d.ts +27 -0
  28. package/dist/pairing.d.ts.map +1 -0
  29. package/dist/pairing.js +148 -0
  30. package/dist/pairing.js.map +1 -0
  31. package/dist/project.d.ts +3 -1
  32. package/dist/project.d.ts.map +1 -1
  33. package/dist/project.js +33 -6
  34. package/dist/project.js.map +1 -1
  35. package/dist/publication.d.ts +20 -5
  36. package/dist/publication.d.ts.map +1 -1
  37. package/dist/publication.js +58 -16
  38. package/dist/publication.js.map +1 -1
  39. package/dist/terminal.d.ts +10 -0
  40. package/dist/terminal.d.ts.map +1 -0
  41. package/dist/terminal.js +70 -0
  42. package/dist/terminal.js.map +1 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +2 -2
package/dist/main.js CHANGED
@@ -14,33 +14,45 @@
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';
18
19
  import { CLI_VERSION } from './version.js';
19
20
  import { prepareArtifact } from './artifact.js';
20
- 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';
21
23
  import { discoverFlows, inventoryJson } from './discovery.js';
22
- import { CliError, EXIT_CODE, artifactError, authorizationError, projectError, usageError, } from './errors.js';
24
+ import { CliError, EXIT_CODE, artifactError, authorizationError, contractError, projectError, usageError, } from './errors.js';
23
25
  import { isDigest, isRelease, REQUIREMENT_KINDS } from './internal/names.js';
26
+ import { DEFAULT_ISSUER, PAIRING_PAGE, normalizeCode, pairingIssuer, redeemPairingCode, validateLabel, } from './pairing.js';
24
27
  import { PROJECT_FILE, PROJECT_SCHEMA, findProjectFile, parseProject } from './project.js';
25
- import { activateRelease, publish, readRelease, readUpload, } from './publication.js';
28
+ import { activateRelease, publish, readPointer, readRelease, readUpload, } from './publication.js';
26
29
  export { CLI_VERSION };
27
30
  /**
28
- * The Home Key is read from the environment only. A secret passed as an
29
- * argument would land in shell history, in a process listing and in most CI
30
- * logs, so no command accepts one.
31
+ * A Home Key comes from a paired context in `~/.miakapp` or, for CI and
32
+ * compatibility, from this variable. A secret passed as an argument would land
33
+ * in shell history, in a process listing and in most CI logs, so no command
34
+ * accepts one.
31
35
  */
32
36
  export const HOME_KEY_VARIABLE = 'MIAKAPP_HOME_KEY';
33
- const USAGE = `miakapp ${CLI_VERSION} — build, publish and roll back a Miakapp home component
37
+ /** Selects a stored context for one invocation, like `--context`. */
38
+ export const CONTEXT_VARIABLE = 'MIAKAPP_CONTEXT';
39
+ const USAGE = `miakapp ${CLI_VERSION} — build, publish and roll back a Miakapp home interface
34
40
 
35
41
  Usage
36
42
  miakapp <command> [options]
37
43
 
38
44
  Commands
39
45
  docs start Print the complete agent guide bundled with this CLI
46
+ pair Redeem a one-time pairing code into a stored context
47
+ context list List stored contexts (never prints a key)
48
+ context show [name] Show one context, the current one by default
49
+ context use <name> Make a context current
50
+ context remove <name> Delete a context and its stored key
40
51
  init Write ${PROJECT_FILE} in the current directory
41
52
  agent-pack Install the guide and the MCP wiring into a repository
42
53
  discover Inventory an existing Node-RED installation offline
43
54
  check Validate the project and the artifact offline
55
+ status Read the live generation and release of the home
44
56
  publish Upload, finalize and activate the built artifact
45
57
  activate Activate an already finalized digest at a new generation
46
58
  rollback Alias of activate, for returning to a known-good digest
@@ -51,17 +63,28 @@ Commands
51
63
  version Print the CLI version
52
64
 
53
65
  Common options
66
+ --help, -h Print this guide without running the command
54
67
  --json Print one machine-readable object on stdout
55
68
  --project <dir> Start the ${PROJECT_FILE} search here (default: cwd)
69
+ --context <name> Use this stored context (status, publish, activate,
70
+ rollback, release, upload, init)
71
+
72
+ pair options
73
+ (code) Read from stdin: hidden prompt on a terminal, first
74
+ line of a pipe otherwise. Preferred: never echoed.
75
+ --code <code> Pass the code explicitly (lands in shell history)
76
+ --issuer <https url> Control plane to redeem at (default: ${DEFAULT_ISSUER})
77
+ --label <label> Key label shown to the owner (default: miakapp-cli@host)
78
+ --name <context> Context name (default: the paired home ID)
56
79
 
57
80
  publish options
58
- --expected-generation <n> Generation the pointer is expected to hold (required)
81
+ --expected-generation <n> Generation the pointer holds (default: read live)
59
82
  --generation <n> Generation to publish (default: expected + 1)
60
- --release <name> Override component.release from ${PROJECT_FILE}
83
+ --release <name> Override app.release or component.release from ${PROJECT_FILE}
61
84
 
62
85
  activate / rollback options
63
86
  --sha256 <digest> Finalized artifact digest (required)
64
- --expected-generation <n> Generation the pointer is expected to hold (required)
87
+ --expected-generation <n> Generation the pointer holds (default: read live)
65
88
  --generation <n> Generation to publish (default: expected + 1)
66
89
 
67
90
  discover options
@@ -76,34 +99,46 @@ agent-pack options
76
99
  --dir <path> Repository to install into (default: cwd)
77
100
 
78
101
  init options
79
- --home <homeId> Home ID to write into ${PROJECT_FILE} (required)
80
- --control-plane <https url> Control-plane issuer (required)
81
- --artifact <path> Built artifact path (default: dist/component.js)
102
+ --home <homeId> Home ID (default: from the selected context)
103
+ --control-plane <https url> Control-plane issuer (default: from the context)
104
+ --kind <app|component> Interface kind (default: app)
105
+ --artifact <path> Built artifact path (default: dist/app.js for app,
106
+ dist/component.js for component)
82
107
  --release <name> Initial release name (default: 0.1.0)
83
108
 
109
+ Credentials, highest precedence first
110
+ 1. --context <name> a context stored by miakapp pair
111
+ 2. ${CONTEXT_VARIABLE}=<name> the same, from the environment
112
+ 3. ${HOME_KEY_VARIABLE} a raw Home Key, for CI and compatibility
113
+ 4. the stored context whose home and issuer match ${PROJECT_FILE}
114
+ (the current context wins a tie)
115
+ A stored context whose home or issuer differs from ${PROJECT_FILE} is refused,
116
+ so a key can never publish into another home. Keys are never printed.
117
+
84
118
  Environment
85
- ${HOME_KEY_VARIABLE} Home Key with the components:publish scope. Required by
86
- publish, activate, rollback, release and upload. It is never
87
- accepted as an argument and never printed.
119
+ MIAKAPP_CONFIG_DIR Absolute directory replacing ~/.miakapp
88
120
 
89
121
  Exit codes
90
122
  0 success 1 usage 2 project 3 artifact
91
123
  4 authorization 5 contract 6 conflict 7 unknown outcome
92
124
  `;
93
- const GLOBAL_FLAGS = ['json'];
125
+ const GLOBAL_FLAGS = ['json', 'help'];
94
126
  const GLOBAL_OPTIONS = ['project'];
95
127
  /** Exported so the MCP surface can be proved to expose every option, and no other. */
96
128
  export const COMMAND_OPTIONS = {
97
129
  docs: [],
98
- init: ['home', 'control-plane', 'artifact', 'release'],
130
+ pair: ['code', 'issuer', 'label', 'name'],
131
+ context: [],
132
+ init: ['home', 'control-plane', 'artifact', 'release', 'context', 'kind'],
99
133
  'agent-pack': ['dir'],
100
134
  discover: ['flows'],
101
135
  check: [],
102
- publish: ['expected-generation', 'generation', 'release'],
103
- activate: ['sha256', 'expected-generation', 'generation'],
104
- rollback: ['sha256', 'expected-generation', 'generation'],
105
- release: [],
106
- upload: [],
136
+ status: ['context'],
137
+ publish: ['expected-generation', 'generation', 'release', 'context'],
138
+ activate: ['sha256', 'expected-generation', 'generation', 'context'],
139
+ rollback: ['sha256', 'expected-generation', 'generation', 'context'],
140
+ release: ['context'],
141
+ upload: ['context'],
107
142
  mcp: [],
108
143
  help: [],
109
144
  version: [],
@@ -135,6 +170,10 @@ export function parseArguments(argv) {
135
170
  const positional = [];
136
171
  for (let index = 1; index < argv.length; index += 1) {
137
172
  const argument = argv[index];
173
+ if (argument === '-h') {
174
+ flags.add('help');
175
+ continue;
176
+ }
138
177
  if (!argument.startsWith('--')) {
139
178
  positional.push(argument);
140
179
  continue;
@@ -166,7 +205,7 @@ export function parseArguments(argv) {
166
205
  options.set(name, value);
167
206
  index += 1;
168
207
  }
169
- return { command, options, flags, positional };
208
+ return { command: flags.has('help') ? 'help' : command, options, flags, positional };
170
209
  }
171
210
  function requiredOption(invocation, name) {
172
211
  const value = invocation.options.get(name);
@@ -184,17 +223,34 @@ function generationOption(invocation, name) {
184
223
  throw usageError(`--${name} is above the safe integer range`);
185
224
  return value;
186
225
  }
187
- /** `--generation` defaults to one above the expected generation, never higher. */
188
- function generationPair(invocation) {
189
- const expectedGeneration = generationOption(invocation, 'expected-generation');
226
+ /** Validated before any network use, so a typo never costs a request. */
227
+ function generationPlan(invocation) {
228
+ const expectedGeneration = invocation.options.has('expected-generation')
229
+ ? generationOption(invocation, 'expected-generation')
230
+ : undefined;
190
231
  const generation = invocation.options.has('generation')
191
232
  ? generationOption(invocation, 'generation')
192
- : expectedGeneration + 1;
193
- if (generation <= expectedGeneration) {
233
+ : undefined;
234
+ if (expectedGeneration !== undefined && generation !== undefined
235
+ && generation <= expectedGeneration) {
194
236
  throw usageError('--generation must be strictly above --expected-generation');
195
237
  }
196
238
  return { expectedGeneration, generation };
197
239
  }
240
+ /**
241
+ * Resolves the compare-and-set pair. Without `--expected-generation` the live
242
+ * generation is read first. That read takes no lock: the activation is still a
243
+ * compare-and-set, so a publication that lands in between fails with
244
+ * `conflict` rather than being overwritten.
245
+ */
246
+ async function resolveGenerations(target, plan) {
247
+ const expectedGeneration = plan.expectedGeneration ?? (await readPointer(target)).generation;
248
+ const generation = plan.generation ?? expectedGeneration + 1;
249
+ if (generation <= expectedGeneration) {
250
+ throw usageError(`--generation must be strictly above the live generation ${expectedGeneration}`);
251
+ }
252
+ return { expectedGeneration, generation };
253
+ }
198
254
  function digestOption(invocation, name) {
199
255
  const value = requiredOption(invocation, name);
200
256
  if (!isDigest(value)) {
@@ -202,13 +258,95 @@ function digestOption(invocation, name) {
202
258
  }
203
259
  return value;
204
260
  }
205
- function homeKey(host) {
206
- const value = host.env(HOME_KEY_VARIABLE);
207
- if (value === undefined || value === '') {
208
- throw authorizationError(`${HOME_KEY_VARIABLE} is not set`, `Export a Home Key holding components:publish as ${HOME_KEY_VARIABLE}. `
209
- + 'It is never accepted as a command-line argument.');
261
+ function nonEmpty(value) {
262
+ return value === undefined || value === '' ? undefined : value;
263
+ }
264
+ function storeDirectory(host) {
265
+ return configDirectory((name) => host.env(name), host.homeDirectory?.() ?? homedir());
266
+ }
267
+ /** The context named for this invocation, by flag first and then environment. */
268
+ function explicitContext(host, invocation) {
269
+ const name = invocation.options.get('context') ?? nonEmpty(host.env(CONTEXT_VARIABLE));
270
+ if (name !== undefined && !isContextName(name))
271
+ contextNameError(name);
272
+ return name;
273
+ }
274
+ /**
275
+ * The wrong-home guard. The project file says where a publication goes; a
276
+ * context says which home its key opens. When the two disagree the command
277
+ * stops, because the control plane would accept a valid key for home A
278
+ * publishing into home A, while the person meant home B.
279
+ */
280
+ function requireSameHome(context, project, origin) {
281
+ if (context.homeId === project.homeId && context.issuer === project.issuer)
282
+ return;
283
+ throw authorizationError(`${origin} is for home ${context.homeId} at ${context.issuer}, but ${PROJECT_FILE} targets `
284
+ + `home ${project.homeId} at ${project.issuer}`, 'Select the context paired with this home (miakapp context list), or pair it with '
285
+ + 'miakapp pair. Nothing was sent.');
286
+ }
287
+ /**
288
+ * Picks the Home Key for one invocation. Precedence, highest first:
289
+ *
290
+ * 1. `--context <name>`, then `MIAKAPP_CONTEXT`: an explicit stored context;
291
+ * 2. `MIAKAPP_HOME_KEY`: a raw key, for CI and compatibility;
292
+ * 3. the stored context paired with the project's home and issuer, the
293
+ * current context breaking a tie between several keys for that one home.
294
+ *
295
+ * A stored context is used only for the home it was paired with, and a raw
296
+ * key known to belong to another stored home is refused the same way.
297
+ */
298
+ async function resolveCredential(host, invocation, project) {
299
+ const directory = storeDirectory(host);
300
+ const explicit = explicitContext(host, invocation);
301
+ if (explicit !== undefined) {
302
+ const { context, homeKey } = await readCredential(directory, explicit);
303
+ requireSameHome(context, project, `Context ${context.name}`);
304
+ return { homeKey, source: 'context', context };
210
305
  }
211
- return value;
306
+ const environmentKey = nonEmpty(host.env(HOME_KEY_VARIABLE));
307
+ if (environmentKey !== undefined) {
308
+ const keyId = homeKeyId(environmentKey);
309
+ let known = [];
310
+ try {
311
+ known = (await readContexts(directory)).contexts.values();
312
+ }
313
+ catch {
314
+ // The guard is best effort here: a CI runner has no store, and a broken
315
+ // one must not block a key the caller supplied explicitly.
316
+ }
317
+ for (const context of known) {
318
+ if (context.keyId === keyId) {
319
+ requireSameHome(context, project, `${HOME_KEY_VARIABLE} (paired as context ${context.name})`);
320
+ }
321
+ }
322
+ return { homeKey: environmentKey, source: 'environment', context: undefined };
323
+ }
324
+ const state = await readContexts(directory);
325
+ const matching = [...state.contexts.values()]
326
+ .filter((context) => context.homeId === project.homeId && context.issuer === project.issuer)
327
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
328
+ const chosen = matching.find((context) => context.name === state.current) ?? matching[0];
329
+ if (chosen === undefined) {
330
+ const others = [...state.contexts.keys()];
331
+ throw authorizationError(`No credential for home ${project.homeId} at ${project.issuer}`, 'Pair this home: ask the owner to open '
332
+ + `${PAIRING_PAGE}, choose it, confirm access and send you the code, then run `
333
+ + `miakapp pair. ${HOME_KEY_VARIABLE} also works, for CI.`
334
+ + (others.length === 0 ? '' : ` Stored contexts are for other homes: ${others.join(', ')}.`));
335
+ }
336
+ const { homeKey } = await readCredential(directory, chosen.name);
337
+ return { homeKey, source: 'context', context: chosen };
338
+ }
339
+ function credentialJson(credential) {
340
+ return {
341
+ credential_source: credential.source,
342
+ context: credential.context?.name ?? null,
343
+ };
344
+ }
345
+ function credentialFields(credential) {
346
+ return [[
347
+ 'credential',
348
+ credential.context === undefined ? HOME_KEY_VARIABLE : `context ${credential.context.name}`,
349
+ ]];
212
350
  }
213
351
  async function nodeFileSystem() {
214
352
  const fs = await import('node:fs/promises');
@@ -260,30 +398,47 @@ async function loadArtifact(host, project) {
260
398
  }
261
399
  return prepareArtifact(await filesystem.read(project.artifactPath));
262
400
  }
263
- async function publicationTarget(host, project) {
264
- const key = homeKey(host);
401
+ async function publicationTarget(host, invocation, project) {
402
+ const credential = await resolveCredential(host, invocation, project);
265
403
  const options = host.fetch === undefined ? {} : { fetch: host.fetch };
266
404
  const discovery = await fetchDiscovery({ issuer: project.issuer, ...options });
267
- const token = await exchangePublisherToken(discovery, key, options);
268
- return { issuer: discovery.issuer, homeId: project.homeId, token: token.accessToken, ...options };
405
+ const token = await exchangePublisherToken(discovery, credential.homeKey, options);
406
+ return {
407
+ target: { issuer: discovery.issuer, homeId: project.homeId, token: token.accessToken, ...options },
408
+ credential,
409
+ homeUrl: homeUrl(discovery, project.homeId),
410
+ };
269
411
  }
270
412
  function requirementFields(requires) {
271
413
  return REQUIREMENT_KINDS.map((kind) => [`requires.${kind}`, requires[kind]]);
272
414
  }
273
- function pointerResult(summary, pointer) {
415
+ /**
416
+ * `home_url` is the link residents open — the trusted Miakapp shell for this
417
+ * home, from discovery — and the only link to hand a person. `url` stays the
418
+ * raw artifact the shell verifies and runs; it is printed as `artifact_url` so
419
+ * it is never mistaken for a page.
420
+ */
421
+ function pointerResult(summary, pointer, credential, expectedGeneration, residentUrl) {
274
422
  return {
275
423
  summary,
276
424
  fields: [
425
+ ...credentialFields(credential),
426
+ ...(expectedGeneration === undefined
427
+ ? []
428
+ : [['expected_generation', expectedGeneration]]),
277
429
  ['home', pointer.homeId],
430
+ ['home_url', residentUrl ?? 'not advertised by this control plane'],
431
+ ['abi', pointer.abi],
278
432
  ['generation', pointer.generation],
279
433
  ['release', pointer.release],
280
434
  ['sha256', pointer.sha256],
281
435
  ['size', pointer.size],
282
- ['url', pointer.url],
436
+ ['artifact_url', pointer.url],
283
437
  ...requirementFields(pointer.requires),
284
438
  ],
285
439
  json: {
286
440
  home_id: pointer.homeId,
441
+ home_url: residentUrl,
287
442
  generation: pointer.generation,
288
443
  release: pointer.release,
289
444
  abi: pointer.abi,
@@ -291,10 +446,29 @@ function pointerResult(summary, pointer) {
291
446
  sha256: pointer.sha256,
292
447
  size: pointer.size,
293
448
  requires: pointer.requires,
449
+ ...(expectedGeneration === undefined ? {} : { expected_generation: expectedGeneration }),
450
+ ...credentialJson(credential),
294
451
  },
295
452
  };
296
453
  }
297
454
  function projectTemplate(fields) {
455
+ if (fields.kind === 'app') {
456
+ return `schema: ${PROJECT_SCHEMA}
457
+ home: ${fields.home}
458
+ control_plane: ${fields.controlPlane}
459
+
460
+ # A whole-house application: one self-contained classic-script (IIFE) bundle
461
+ # that draws its own interface in Miakapp's isolated frame. Every path read and
462
+ # every function called must be declared; the coordinator still decides, per
463
+ # resident, what each one may see and do.
464
+ app:
465
+ artifact: ${fields.artifact}
466
+ release: ${fields.release}
467
+ requires:
468
+ state_read: []
469
+ call: []
470
+ `;
471
+ }
298
472
  return `schema: ${PROJECT_SCHEMA}
299
473
  home: ${fields.home}
300
474
  control_plane: ${fields.controlPlane}
@@ -321,19 +495,72 @@ async function runInit(host, invocation) {
321
495
  if (!isRelease(release)) {
322
496
  throw usageError('--release must be 1..64 UTF-8 bytes without control characters');
323
497
  }
498
+ const kind = invocation.options.get('kind') ?? 'app';
499
+ if (kind !== 'app' && kind !== 'component') {
500
+ throw usageError('--kind must be app (whole-house application, default) or component');
501
+ }
502
+ const target = await initTarget(host, invocation);
324
503
  const source = projectTemplate({
325
- home: requiredOption(invocation, 'home'),
326
- controlPlane: requiredOption(invocation, 'control-plane'),
327
- artifact: invocation.options.get('artifact') ?? 'dist/component.js',
504
+ home: target.home,
505
+ controlPlane: target.controlPlane,
506
+ artifact: invocation.options.get('artifact') ?? (kind === 'app' ? 'dist/app.js' : 'dist/component.js'),
328
507
  release,
508
+ kind,
329
509
  });
330
510
  // Parsed before it is written, so init can never emit a file check rejects.
331
511
  parseProject(root, source);
332
512
  await filesystem.write(path, new TextEncoder().encode(source));
333
513
  return {
334
514
  summary: `Wrote ${path}`,
335
- fields: [['project', path]],
336
- json: { project: path, schema: PROJECT_SCHEMA },
515
+ fields: [
516
+ ['project', path],
517
+ ['home', target.home],
518
+ ['control_plane', target.controlPlane],
519
+ ...(target.context === undefined ? [] : [['context', target.context]]),
520
+ ],
521
+ json: {
522
+ project: path,
523
+ schema: PROJECT_SCHEMA,
524
+ kind,
525
+ home_id: target.home,
526
+ control_plane: target.controlPlane,
527
+ context: target.context ?? null,
528
+ },
529
+ };
530
+ }
531
+ /**
532
+ * `--home` and `--control-plane` default to the selected context — the one
533
+ * named by `--context` or `MIAKAPP_CONTEXT`, else the current one — so the step
534
+ * after `miakapp pair` needs no identifier typed by hand. An explicit value
535
+ * that contradicts an explicitly selected context is refused rather than
536
+ * silently preferred.
537
+ */
538
+ async function initTarget(host, invocation) {
539
+ const home = invocation.options.get('home');
540
+ const controlPlane = invocation.options.get('control-plane');
541
+ const explicit = explicitContext(host, invocation);
542
+ if (explicit === undefined && home !== undefined && controlPlane !== undefined) {
543
+ return { home, controlPlane, context: undefined };
544
+ }
545
+ const state = await readContexts(storeDirectory(host));
546
+ const name = explicit ?? state.current ?? undefined;
547
+ const context = name === undefined ? undefined : state.contexts.get(name);
548
+ if (explicit !== undefined && context === undefined) {
549
+ throw usageError(`No context named ${explicit}`, 'Run miakapp context list.');
550
+ }
551
+ if (context === undefined) {
552
+ 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.');
553
+ }
554
+ if (explicit !== undefined
555
+ && ((home !== undefined && home !== context.homeId)
556
+ || (controlPlane !== undefined && controlPlane !== context.issuer))) {
557
+ throw usageError(`Context ${context.name} is for home ${context.homeId} at ${context.issuer}, which `
558
+ + 'contradicts --home or --control-plane');
559
+ }
560
+ return {
561
+ home: home ?? context.homeId,
562
+ controlPlane: controlPlane ?? context.issuer,
563
+ context: context.name,
337
564
  };
338
565
  }
339
566
  /**
@@ -483,6 +710,7 @@ async function runCheck(host, invocation) {
483
710
  fields: [
484
711
  ['home', project.homeId],
485
712
  ['control_plane', project.issuer],
713
+ ['abi', project.abi],
486
714
  ['release', project.release],
487
715
  ['artifact', project.artifactPath],
488
716
  ['sha256', artifact.sha256],
@@ -493,6 +721,7 @@ async function runCheck(host, invocation) {
493
721
  json: {
494
722
  home_id: project.homeId,
495
723
  control_plane: project.issuer,
724
+ abi: project.abi,
496
725
  release: project.release,
497
726
  artifact: project.artifactPath,
498
727
  sha256: artifact.sha256,
@@ -503,35 +732,38 @@ async function runCheck(host, invocation) {
503
732
  };
504
733
  }
505
734
  async function runPublish(host, invocation) {
506
- const { expectedGeneration, generation } = generationPair(invocation);
735
+ const plan = generationPlan(invocation);
507
736
  const project = await loadProject(host, invocation);
508
737
  const release = invocation.options.get('release') ?? project.release;
509
738
  if (!isRelease(release)) {
510
739
  throw usageError('--release must be 1..64 UTF-8 bytes without control characters');
511
740
  }
512
741
  const artifact = await loadArtifact(host, project);
513
- const target = await publicationTarget(host, project);
742
+ const { target, credential, homeUrl: residentUrl } = await publicationTarget(host, invocation, project);
743
+ const { expectedGeneration, generation } = await resolveGenerations(target, plan);
514
744
  const { pointer } = await publish(target, artifact, {
515
745
  release,
746
+ abi: project.abi,
516
747
  requires: project.requires,
517
748
  expectedGeneration,
518
749
  generation,
519
750
  });
520
- return pointerResult(`Published ${release} as generation ${pointer.generation}`, pointer);
751
+ return pointerResult(`Published ${release} as generation ${pointer.generation}`, pointer, credential, expectedGeneration, residentUrl);
521
752
  }
522
753
  async function runActivate(host, invocation) {
523
754
  const sha256 = digestOption(invocation, 'sha256');
524
- const { expectedGeneration, generation } = generationPair(invocation);
755
+ const plan = generationPlan(invocation);
525
756
  const project = await loadProject(host, invocation);
526
- const target = await publicationTarget(host, project);
757
+ const { target, credential, homeUrl: residentUrl } = await publicationTarget(host, invocation, project);
527
758
  // Activation is checked against a readable finalized record first, so a typo
528
759
  // in a digest fails as an artifact error instead of spending a CAS attempt.
529
760
  const existing = await readRelease(target, sha256);
530
761
  if (existing === undefined) {
531
762
  throw artifactError(`No finalized release for ${sha256}`, 'Activate only a digest this home has already published.');
532
763
  }
764
+ const { expectedGeneration, generation } = await resolveGenerations(target, plan);
533
765
  const pointer = await activateRelease(target, { sha256, expectedGeneration, generation });
534
- return pointerResult(`Activated ${existing.release} as generation ${pointer.generation}`, pointer);
766
+ return pointerResult(`Activated ${existing.release} as generation ${pointer.generation}`, pointer, credential, expectedGeneration, residentUrl);
535
767
  }
536
768
  async function runRelease(host, invocation) {
537
769
  const sha256 = invocation.positional[0];
@@ -541,7 +773,7 @@ async function runRelease(host, invocation) {
541
773
  throw usageError('The release digest must be 43 base64url characters');
542
774
  }
543
775
  const project = await loadProject(host, invocation);
544
- const target = await publicationTarget(host, project);
776
+ const { target } = await publicationTarget(host, invocation, project);
545
777
  const record = await readRelease(target, sha256);
546
778
  if (record === undefined) {
547
779
  throw artifactError(`No finalized release for ${sha256}`);
@@ -570,7 +802,7 @@ async function runUpload(host, invocation) {
570
802
  if (uploadId === undefined)
571
803
  throw usageError('upload requires one uploadId argument');
572
804
  const project = await loadProject(host, invocation);
573
- const target = await publicationTarget(host, project);
805
+ const { target } = await publicationTarget(host, invocation, project);
574
806
  const state = await readUpload(target, uploadId);
575
807
  return {
576
808
  summary: `Upload ${state.uploadId} is ${state.status}`,
@@ -594,6 +826,281 @@ async function runUpload(host, invocation) {
594
826
  },
595
827
  };
596
828
  }
829
+ async function runStatus(host, invocation) {
830
+ const project = await loadProject(host, invocation);
831
+ const { target, credential, homeUrl: residentUrl } = await publicationTarget(host, invocation, project);
832
+ const { generation, pointer } = await readPointer(target);
833
+ if (pointer === null) {
834
+ return {
835
+ summary: `${project.homeId} has never activated an interface (generation 0)`,
836
+ fields: [
837
+ ['home', project.homeId],
838
+ ['home_url', residentUrl ?? 'not advertised by this control plane'],
839
+ ['generation', 0],
840
+ ...credentialFields(credential),
841
+ ],
842
+ json: {
843
+ home_id: project.homeId,
844
+ home_url: residentUrl,
845
+ generation,
846
+ active: false,
847
+ ...credentialJson(credential),
848
+ },
849
+ };
850
+ }
851
+ const result = pointerResult(`${project.homeId} runs ${pointer.release} at generation ${generation}`, pointer, credential, undefined, residentUrl);
852
+ return { ...result, json: { ...result.json, active: true } };
853
+ }
854
+ function defaultLabel(host) {
855
+ const machine = (host.hostname?.() ?? hostname()).replace(/[^A-Za-z0-9._-]/g, '-');
856
+ return `miakapp-cli@${machine === '' ? 'unknown' : machine}`.slice(0, 64);
857
+ }
858
+ /** A free context name: the base itself, else base-2, base-3, ... */
859
+ function freeContextName(taken, base) {
860
+ if (!taken.has(base))
861
+ return base;
862
+ for (let suffix = 2;; suffix += 1) {
863
+ const candidate = `${base.slice(0, 62 - String(suffix).length)}-${suffix}`;
864
+ if (!taken.has(candidate))
865
+ return candidate;
866
+ }
867
+ }
868
+ async function pairingCode(host, invocation) {
869
+ const explicit = invocation.options.get('code');
870
+ if (explicit !== undefined)
871
+ return explicit;
872
+ if (host.readSecret === undefined) {
873
+ 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 '
874
+ + 'be prompted without echo.');
875
+ }
876
+ return await host.readSecret('Pairing code (hidden): ');
877
+ }
878
+ /**
879
+ * Trades a one-time pairing code for a fresh Home Key and stores it as a new
880
+ * context. Every existing context is kept. The new context becomes current.
881
+ *
882
+ * Everything that can fail without spending the code — the issuer, the label,
883
+ * the name, the readability of the store and the issuer's discovery document —
884
+ * is checked before the code is sent, because a code works exactly once.
885
+ */
886
+ async function runPair(host, invocation) {
887
+ const issuer = pairingIssuer(invocation.options.get('issuer') ?? DEFAULT_ISSUER);
888
+ const label = validateLabel(invocation.options.get('label') ?? defaultLabel(host));
889
+ const requestedName = invocation.options.get('name');
890
+ if (requestedName !== undefined && !isContextName(requestedName))
891
+ contextNameError(requestedName);
892
+ const directory = storeDirectory(host);
893
+ const before = await readContexts(directory);
894
+ if (requestedName !== undefined && before.contexts.has(requestedName)) {
895
+ throw usageError(`A context named ${requestedName} already exists`, `Choose another --name, or remove it first with miakapp context remove ${requestedName}. `
896
+ + 'The code was not used.');
897
+ }
898
+ const code = normalizeCode(await pairingCode(host, invocation));
899
+ const options = host.fetch === undefined ? {} : { fetch: host.fetch };
900
+ let discovery;
901
+ try {
902
+ discovery = await fetchDiscovery({ issuer, ...options });
903
+ }
904
+ catch (error) {
905
+ const reason = error instanceof Error ? error.message : 'unknown failure';
906
+ 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 '
907
+ + 'network, then run pair again with the same code.');
908
+ }
909
+ const paired = await redeemPairingCode({ issuer, code, label, ...options });
910
+ const createdAt = new Date().toISOString();
911
+ let stored;
912
+ try {
913
+ stored = await mutateContexts(directory, (state) => {
914
+ const previous = [...state.contexts.values()]
915
+ .filter((context) => context.homeId === paired.homeId && context.issuer === paired.issuer);
916
+ const name = freeContextName(state.contexts, requestedName ?? paired.homeId);
917
+ state.contexts.set(name, {
918
+ name,
919
+ issuer: paired.issuer,
920
+ homeId: paired.homeId,
921
+ keyId: paired.keyId,
922
+ label,
923
+ createdAt,
924
+ });
925
+ state.keys.set(name, paired.homeKey);
926
+ state.current = name;
927
+ return { name, previous };
928
+ });
929
+ }
930
+ catch (error) {
931
+ const reason = error instanceof Error ? error.message : 'unknown failure';
932
+ throw authorizationError(`Home Key ${paired.keyId} for ${paired.homeId} was issued but could not be stored in `
933
+ + `${directory}: ${reason}`, `Ask the owner to revoke the key labelled ${JSON.stringify(label)}, fix the directory, then pair again.`);
934
+ }
935
+ // The key is stored; proving it can obtain a publication token tells the
936
+ // agent now, rather than at its first publish, whether the grant is usable.
937
+ let publishAccess = 'verified';
938
+ let publishAccessDetail;
939
+ try {
940
+ await exchangePublisherToken(discovery, paired.homeKey, options);
941
+ }
942
+ catch (error) {
943
+ publishAccess = 'refused';
944
+ publishAccessDetail = error instanceof Error ? error.message : 'unknown failure';
945
+ }
946
+ const previous = stored.previous.map((context) => context.name);
947
+ return {
948
+ summary: `Paired ${paired.homeId} as context ${stored.name}`
949
+ + (publishAccess === 'verified' ? '' : ' (the key could not obtain a publication token)'),
950
+ fields: [
951
+ ['context', stored.name],
952
+ ['home', paired.homeId],
953
+ ['issuer', paired.issuer],
954
+ ['key_id', paired.keyId],
955
+ ['label', label],
956
+ ['current', 'yes'],
957
+ ['publish_access', publishAccessDetail === undefined ? publishAccess : `${publishAccess}: ${publishAccessDetail}`],
958
+ ['config', directory],
959
+ ...(previous.length === 0 ? [] : [['earlier_contexts_for_this_home', previous]]),
960
+ ],
961
+ json: {
962
+ context: stored.name,
963
+ home_id: paired.homeId,
964
+ issuer: paired.issuer,
965
+ key_id: paired.keyId,
966
+ label,
967
+ current: true,
968
+ publish_access: publishAccess,
969
+ ...(publishAccessDetail === undefined ? {} : { publish_access_detail: publishAccessDetail }),
970
+ config_directory: directory,
971
+ earlier_contexts_for_this_home: previous,
972
+ },
973
+ };
974
+ }
975
+ function contextJson(context, current, credential) {
976
+ return {
977
+ name: context.name,
978
+ home_id: context.homeId,
979
+ issuer: context.issuer,
980
+ key_id: context.keyId,
981
+ label: context.label,
982
+ created_at: context.createdAt,
983
+ current: context.name === current,
984
+ credential: credential ?? 'missing',
985
+ };
986
+ }
987
+ /**
988
+ * `context list | show [name] | use <name> | remove <name>`.
989
+ *
990
+ * Nothing here prints a Home Key: `credential` reports only whether the stored
991
+ * key is present and is the key the context was paired with.
992
+ */
993
+ async function runContext(host, invocation) {
994
+ const [action, name, extra] = invocation.positional;
995
+ const directory = storeDirectory(host);
996
+ if (extra !== undefined)
997
+ throw usageError('context takes at most one name');
998
+ const named = (verb) => {
999
+ if (name === undefined)
1000
+ throw usageError(`context ${verb} requires a context name`);
1001
+ if (!isContextName(name))
1002
+ contextNameError(name);
1003
+ return name;
1004
+ };
1005
+ switch (action) {
1006
+ case 'list': {
1007
+ if (name !== undefined)
1008
+ throw usageError('context list takes no name');
1009
+ const state = await readContexts(directory);
1010
+ const contexts = [...state.contexts.values()];
1011
+ return {
1012
+ summary: contexts.length === 0
1013
+ ? `No context in ${directory}. Run miakapp pair to add one.`
1014
+ : `${counted(contexts.length, 'context', 'contexts')} in ${directory}`,
1015
+ fields: contexts.map((context) => [
1016
+ `${context.name === state.current ? '* ' : ''}${context.name}`,
1017
+ `home ${context.homeId} at ${context.issuer}, key ${context.keyId} `
1018
+ + `(${state.credentials.get(context.name) ?? 'missing'})`,
1019
+ ]),
1020
+ json: {
1021
+ config_directory: directory,
1022
+ current_context: state.current,
1023
+ contexts: contexts.map((context) => contextJson(context, state.current, state.credentials.get(context.name))),
1024
+ },
1025
+ };
1026
+ }
1027
+ case 'show': {
1028
+ const state = await readContexts(directory);
1029
+ const selected = name ?? state.current ?? undefined;
1030
+ if (selected === undefined) {
1031
+ throw usageError('No current context', 'Name one: miakapp context show <name>, or pair with miakapp pair.');
1032
+ }
1033
+ if (!isContextName(selected))
1034
+ contextNameError(selected);
1035
+ const context = state.contexts.get(selected);
1036
+ if (context === undefined) {
1037
+ throw usageError(`No context named ${selected}`, 'Run miakapp context list.');
1038
+ }
1039
+ const json = contextJson(context, state.current, state.credentials.get(context.name));
1040
+ return {
1041
+ summary: `Context ${context.name}${json['current'] === true ? ' (current)' : ''}`,
1042
+ fields: [
1043
+ ['home', context.homeId],
1044
+ ['issuer', context.issuer],
1045
+ ['key_id', context.keyId],
1046
+ ['label', context.label],
1047
+ ['created_at', context.createdAt],
1048
+ ['credential', `${String(json['credential'])} (never printed)`],
1049
+ ['config', directory],
1050
+ ],
1051
+ json: { ...json, config_directory: directory },
1052
+ };
1053
+ }
1054
+ case 'use': {
1055
+ const selected = named('use');
1056
+ const context = await mutateContexts(directory, (state) => {
1057
+ const found = state.contexts.get(selected);
1058
+ if (found === undefined) {
1059
+ throw usageError(`No context named ${selected}`, 'Run miakapp context list.');
1060
+ }
1061
+ state.current = selected;
1062
+ return found;
1063
+ });
1064
+ return {
1065
+ summary: `Current context is now ${selected}`,
1066
+ fields: [['home', context.homeId], ['issuer', context.issuer]],
1067
+ json: { current_context: selected, home_id: context.homeId, issuer: context.issuer },
1068
+ };
1069
+ }
1070
+ case 'remove': {
1071
+ const selected = named('remove');
1072
+ const result = await mutateContexts(directory, (state) => {
1073
+ const found = state.contexts.get(selected);
1074
+ if (found === undefined) {
1075
+ throw usageError(`No context named ${selected}`, 'Run miakapp context list.');
1076
+ }
1077
+ state.contexts.delete(selected);
1078
+ state.keys.delete(selected);
1079
+ const wasCurrent = state.current === selected;
1080
+ if (wasCurrent)
1081
+ state.current = null;
1082
+ return { context: found, wasCurrent };
1083
+ });
1084
+ return {
1085
+ summary: `Removed context ${selected} and its stored key`,
1086
+ fields: [
1087
+ ['home', result.context.homeId],
1088
+ ['key_id', result.context.keyId],
1089
+ ['still_valid_on_server', 'yes: ask the owner to revoke it if it is no longer needed'],
1090
+ ],
1091
+ json: {
1092
+ removed: selected,
1093
+ home_id: result.context.homeId,
1094
+ key_id: result.context.keyId,
1095
+ was_current: result.wasCurrent,
1096
+ revoked: false,
1097
+ },
1098
+ };
1099
+ }
1100
+ default:
1101
+ 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>.');
1102
+ }
1103
+ }
597
1104
  /**
598
1105
  * Runs one parsed invocation.
599
1106
  *
@@ -604,6 +1111,12 @@ export async function dispatch(host, invocation) {
604
1111
  switch (invocation.command) {
605
1112
  case 'docs':
606
1113
  return await runDocs(host, invocation);
1114
+ case 'pair':
1115
+ return await runPair(host, invocation);
1116
+ case 'context':
1117
+ return await runContext(host, invocation);
1118
+ case 'status':
1119
+ return await runStatus(host, invocation);
607
1120
  case 'init':
608
1121
  return await runInit(host, invocation);
609
1122
  case 'agent-pack':