@miakapp/cli 4.0.0-alpha.4 → 4.0.0-alpha.6

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