@antasphere/chassis-cli 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/context.ts CHANGED
@@ -1,24 +1,29 @@
1
1
  import { PlatformApiError, type ChassisClient, type ClientOptions } from '@antasphere/chassis-sdk';
2
2
  import type { Command } from 'commander';
3
3
  import {
4
- activeHubProfile,
4
+ cachedConnectKey,
5
5
  CLOUD_PROFILE,
6
6
  CliAuthClient,
7
7
  CliUsageError,
8
8
  connectOnDemand,
9
- lookupConnectKey,
9
+ firstSet,
10
+ HUB_TOOL,
11
+ loadConfig as loadCoreConfig,
10
12
  printJson as corePrintJson,
11
13
  refreshConnectKey,
12
14
  resolveApiKey,
13
15
  resolveBaseUrl,
14
16
  sameInstance,
17
+ saveConfig as saveCoreConfig,
18
+ selectHubProfile,
15
19
  selectProfile,
16
20
  type CliIo as CoreCliIo,
17
21
  type ConnectOnDemandOptions,
22
+ type ConnectOnDemandOutcome,
18
23
  type ProfileSource
19
24
  } from '@antasphere/cli-core';
20
25
  import type { CliConfig, CliConfigStore, CliProfile } from './config.js';
21
- import type { CliIdentity } from './identity.js';
26
+ import { DEFAULT_HUB_URL, type CliIdentity } from './identity.js';
22
27
  import {
23
28
  isWorkspaceId,
24
29
  matchWorkspace,
@@ -216,6 +221,15 @@ export interface CliContextKit<TClient extends ChassisClient<string>> {
216
221
  profileUrl(profile: CliProfile | undefined): string;
217
222
  /** The `connectOnDemand` options of one context (the tool, the instance, the profile, the wire). */
218
223
  connectOptions(ctx: CliContext<TClient>): ConnectOnDemandOptions;
224
+ /**
225
+ * The hub exchange of one context: cli-core `connectOnDemand` with
226
+ * `connectOptions(ctx)` and `overrides`, and, after a fresh exchange, the
227
+ * hub profile healed with the address the hub answered when it has none.
228
+ */
229
+ connect(
230
+ ctx: CliContext<TClient>,
231
+ overrides?: Partial<ConnectOnDemandOptions>
232
+ ): Promise<ConnectOnDemandOutcome>;
219
233
  }
220
234
 
221
235
  /**
@@ -305,15 +319,18 @@ export function createContext<TClient extends ChassisClient<string>>(input: {
305
319
  ? 'profile'
306
320
  : 'none';
307
321
  if (!apiKey && sameProfileInstance) {
308
- // No direct key: the connect cache's slot is the ACTIVE hub profile
309
- // (what `antasphere login` stored) — org-independent by design. A key
310
- // cached for ANOTHER account than the login now stored on that slot
311
- // (`antasphere login --email other` since) is never served: it is the
312
- // other person's credential (PRDCT-3032); connectForKey retires it.
313
- const hub = activeHubProfile(io.env);
314
- if (hub.name && !cachedForAnotherAccount(profile, hub.name, hub.profile?.email)) {
315
- apiKey = lookupConnectKey(profile, hub.name);
316
- }
322
+ // No direct key: the connect cache's slot is the hub profile of this
323
+ // run (the one ANTASPHERE_URL names, else the active one: what
324
+ // `antasphere login` stored), org-independent by design. A key is
325
+ // served only to the address that minted it (cli-core
326
+ // `cachedConnectKey`): another account's key is the other person's
327
+ // credential (PRDCT-3032), and a missing address on either side, the
328
+ // entry's or the hub profile's, is unknown, so nothing is served
329
+ // (PRDCT-3046); connectForKey retires the entry. This read repeats
330
+ // cli-core's own (connectOnDemand step 0) on purpose: the credential
331
+ // source and the recovering fetch are decided here.
332
+ const hub = selectHubProfile(io.env, { cloudUrl: DEFAULT_HUB_URL });
333
+ if (hub.name) apiKey = cachedConnectKey(profile, hub.name, hub.profile?.email);
317
334
  if (apiKey) credentialSource = 'hub-cache';
318
335
  }
319
336
  const fetchImpl = io.fetch ?? globalThis.fetch.bind(globalThis);
@@ -344,21 +361,6 @@ export function createContext<TClient extends ChassisClient<string>>(input: {
344
361
  return ctx;
345
362
  }
346
363
 
347
- /**
348
- * The cached connect key on `slot` was minted for another Antasphere
349
- * account than the one whose login the slot now holds. Both addresses must
350
- * be known: a cache entry or a hub profile without one is trusted as before.
351
- */
352
- function cachedForAnotherAccount(
353
- profile: CliProfile | undefined,
354
- slot: string,
355
- hubEmail: string | undefined
356
- ): boolean {
357
- const cached = profile?.connectKeys?.[slot]?.email;
358
- if (!cached || !hubEmail) return false;
359
- return cached.toLowerCase() !== hubEmail.toLowerCase();
360
- }
361
-
362
364
  const NO_KEY_MESSAGE =
363
365
  `An API key is required. Sign in (\`${identity.bin} login\`), pass --api-key, or set ` +
364
366
  `${identity.envPrefix}_API_KEY.`;
@@ -376,6 +378,10 @@ export function createContext<TClient extends ChassisClient<string>>(input: {
376
378
  toolBaseUrl: ctx.baseUrl,
377
379
  // A profile with no baseUrl of its own is at the cloud URL (cli-core 0.5.0).
378
380
  cloudUrl: identity.cloudUrl,
381
+ // The same for a hub profile (cli-core 0.6.0, verifier round 1, F4):
382
+ // one with no baseUrl is at the default hub, so ANTASPHERE_URL naming
383
+ // that hub finds it, and the slot it names is the one this kit reads.
384
+ hubCloudUrl: DEFAULT_HUB_URL,
379
385
  profileName: ctx.profileName ?? CLOUD_PROFILE,
380
386
  env: io.env,
381
387
  // Thread the injected fetch so the probe + exchange stay on the test
@@ -418,9 +424,14 @@ export function createContext<TClient extends ChassisClient<string>>(input: {
418
424
  }
419
425
  const outcome = await refreshConnectKey(connectOptions(ctx));
420
426
  if (outcome.outcome === 'not_cloud') return res;
427
+ // The cause before its consequence (verifier round 3, F1): the sign-in
428
+ // is said first, then what the fresh mint healed.
429
+ ctx.io.err.write('The cached key was refused; signed in again through Antasphere.\n');
430
+ // A fresh mint like any other: the hub profile is healed here too, or
431
+ // the next run would retire the key just minted (verifier round 2, F1).
432
+ healHubAddress(ctx, outcome);
421
433
  ctx.apiKey = outcome.key;
422
434
  ctx.credentialSource = 'hub-exchange';
423
- ctx.io.err.write('The cached key was refused; signed in again through Antasphere.\n');
424
435
  const headers = new Headers(init?.headers);
425
436
  headers.set('authorization', `Bearer ${outcome.key}`);
426
437
  return base(input, { ...init, headers });
@@ -515,32 +526,80 @@ export function createContext<TClient extends ChassisClient<string>>(input: {
515
526
  return ` (looked in the ${noun} "${selection.value}", selected by ${describeSelection(selection)})`;
516
527
  }
517
528
 
529
+ async function connect(
530
+ ctx: CliContext<TClient>,
531
+ overrides: Partial<ConnectOnDemandOptions> = {}
532
+ ): Promise<ConnectOnDemandOutcome> {
533
+ const outcome = await connectOnDemand({ ...connectOptions(ctx), ...overrides });
534
+ healHubAddress(ctx, outcome);
535
+ return outcome;
536
+ }
537
+
538
+ /**
539
+ * The hub profile healed with the address the hub answered, after a fresh
540
+ * exchange: when it carries none or a blank one (an older login, a
541
+ * hand-edited file), or one the hub no longer answers (the account changed
542
+ * its address at the hub). Without it the slot would be refused and a key
543
+ * re-minted and revoked on every run, since a key is served only to the
544
+ * address that minted it. The answer is the key owner's own current
545
+ * address, so no other person's address can be written. That profile
546
+ * only, nothing else of the hub's file moves. A replaced address is said
547
+ * on stderr; an absent one is filled in silently. Called after every
548
+ * fresh exchange the kit runs: `connect` and the stranded-key recovery.
549
+ */
550
+ function healHubAddress(ctx: CliContext<TClient>, outcome: ConnectOnDemandOutcome): void {
551
+ // `!outcome.cached`: a served key implies a hub address already equal to
552
+ // the entry's, so only a fresh exchange can have anything to heal; the
553
+ // guard states that intent.
554
+ if (outcome.outcome !== 'connected' || outcome.cached || !outcome.result) return;
555
+ // A chassis tool never answers a blank address; a foreign one must not
556
+ // blank the profile.
557
+ const answered = firstSet(outcome.result.user.email);
558
+ if (!answered) return;
559
+ const hub = selectHubProfile(ctx.io.env, { cloudUrl: DEFAULT_HUB_URL });
560
+ const hubConfig = loadCoreConfig(ctx.io.env, HUB_TOOL);
561
+ const stored = hub.name ? hubConfig.profiles[hub.name] : undefined;
562
+ const had = firstSet(stored?.email);
563
+ if (!hub.name || !stored || had?.toLowerCase() === answered.toLowerCase()) return;
564
+ hubConfig.profiles[hub.name] = { ...stored, email: answered };
565
+ saveCoreConfig(ctx.io.env, HUB_TOOL, hubConfig);
566
+ if (had) {
567
+ ctx.io.err.write(
568
+ `The Antasphere login's address is now ${answered}, replaced from ${had} (the hub's own answer).\n`
569
+ );
570
+ }
571
+ }
572
+
518
573
  async function connectForKey(ctx: CliContext<TClient>): Promise<string> {
519
574
  const { io } = ctx;
520
- // The cached key of another account (the hub login on the slot changed)
521
- // is revoked on its instance and forgotten before the exchange runs for
522
- // the account now signed in (PRDCT-3032). Best effort: a dead key
523
- // answers 401 and is forgotten all the same.
524
- const hub = activeHubProfile(io.env);
575
+ // An entry on the slot the cache refuses to serve (another account's
576
+ // key, the hub login on the slot having changed, PRDCT-3032; or a key
577
+ // whose address, or the hub profile's, is missing, PRDCT-3046) is
578
+ // revoked on its instance and forgotten before the exchange runs for the
579
+ // account now signed in, so the fresh mint never strands it live. Best
580
+ // effort: a dead key answers 401 and is forgotten all the same.
581
+ const hub = selectHubProfile(io.env, { cloudUrl: DEFAULT_HUB_URL });
525
582
  if (ctx.profileName && hub.name) {
526
583
  const profile = loadConfig(io.env).profiles[ctx.profileName];
527
- if (cachedForAnotherAccount(profile, hub.name, hub.profile?.email)) {
528
- const stale = profile?.connectKeys?.[hub.name];
529
- if (stale) {
530
- io.err.write(
531
- `The ${identity.displayName} key cached for ${stale.email ?? 'another Antasphere login'} is retired: ` +
532
- `the Antasphere login is now ${hub.profile?.email ?? 'another account'}.\n`
533
- );
534
- const auth = new CliAuthClient({
535
- baseUrl: profileUrl(profile),
536
- ...(io.fetch ? { fetch: io.fetch } : {})
537
- });
538
- await auth.revoke(stale.apiKey).catch(() => undefined);
539
- removeConnectKey(io.env, ctx.profileName, hub.name);
540
- }
584
+ const stale = profile?.connectKeys?.[hub.name];
585
+ if (stale && !cachedConnectKey(profile, hub.name, hub.profile?.email)) {
586
+ // A blank address is no address: it never reaches a sentence.
587
+ const hubEmail = firstSet(hub.profile?.email);
588
+ io.err.write(
589
+ `The ${identity.displayName} key cached for ${firstSet(stale.email) ?? 'an unknown login'} is retired: ` +
590
+ (hubEmail
591
+ ? `the Antasphere login is now ${hubEmail}.\n`
592
+ : 'the Antasphere login carries no address to match it with.\n')
593
+ );
594
+ const auth = new CliAuthClient({
595
+ baseUrl: profileUrl(profile),
596
+ ...(io.fetch ? { fetch: io.fetch } : {})
597
+ });
598
+ await auth.revoke(stale.apiKey).catch(() => undefined);
599
+ removeConnectKey(io.env, ctx.profileName, hub.name);
541
600
  }
542
601
  }
543
- const outcome = await connectOnDemand(connectOptions(ctx));
602
+ const outcome = await connect(ctx);
544
603
  if (outcome.outcome === 'not_cloud') {
545
604
  // Self-hosted / unreachable: the classic error, byte-identical.
546
605
  throw new CliUsageError(NO_KEY_MESSAGE);
@@ -566,7 +625,8 @@ export function createContext<TClient extends ChassisClient<string>>(input: {
566
625
  explainWorkspaceRefusal,
567
626
  workspaceNotFoundHint,
568
627
  profileUrl,
569
- connectOptions
628
+ connectOptions,
629
+ connect
570
630
  };
571
631
  }
572
632
 
package/src/identity.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  import type { ToolIdentity } from '@antasphere/chassis-contract';
2
2
 
3
+ /**
4
+ * The Antasphere hub every tool signs in through, when nothing names another
5
+ * (`ANTASPHERE_URL`, a hub profile's own `baseUrl`): the instance of the
6
+ * implicit `cloud` hub profile, and the cloud URL the hub profile is selected
7
+ * with (cli-core `selectHubProfile`, `hubCloudUrl`), so that a hub profile
8
+ * with no `baseUrl` of its own is at this hub, as the account CLI reads it.
9
+ */
10
+ export const DEFAULT_HUB_URL = 'https://account.antasphere.com';
11
+
3
12
  /**
4
13
  * What a tool is called, in every place the generic CLI spells it. ONE object,
5
14
  * passed by the tool to `defineCli` (index.ts): the chassis holds no product
@@ -21,6 +30,11 @@ export interface CliIdentity {
21
30
  keyPrefix: string;
22
31
  /** The scope a key needs for the full-workspace export, as `export --help` names it. */
23
32
  exportScope: string;
33
+ /**
34
+ * The scope a key needs to manage webhooks (`identity.scopes.webhooks`), as
35
+ * the `webhooks` refusals name it; absent, the tool opens webhooks to no key.
36
+ */
37
+ webhooksScope?: string | undefined;
24
38
  /**
25
39
  * The tool's cloud home (`identity.cloud.url`): the last step of the URL
26
40
  * resolution and the instance of the implicit `cloud` profile, so a clean
@@ -43,6 +57,7 @@ export function cliIdentity(identity: ToolIdentity): CliIdentity {
43
57
  envPrefix: identity.cli.envPrefix,
44
58
  keyPrefix: identity.apiKeyPrefix,
45
59
  exportScope: identity.scopes.dataExport,
60
+ webhooksScope: identity.scopes.webhooks,
46
61
  cloudUrl: identity.cloud.url
47
62
  };
48
63
  }
package/src/index.ts CHANGED
@@ -55,6 +55,7 @@ export { isInteractive, openInBrowser, platformOpener } from './open.js';
55
55
 
56
56
  export { safeDownloadName } from './commands/files.js';
57
57
  export { explainProjectRefusal, roleNamedBy, memberRef, type ProjectLookup } from './commands/projects.js';
58
+ export { explainWebhookRefusal, type WebhookLookup } from './commands/webhooks.js';
58
59
  export { CliApiRefusal, drainPages } from './context.js';
59
60
  export type { Cli, CliDefinition, CliKit, RegisterTool } from './kit.js';
60
61
 
package/src/program.ts CHANGED
@@ -8,6 +8,7 @@ import { registerAuthCommands } from './commands/auth.js';
8
8
  import { registerWorkspaceCommands } from './commands/workspaces.js';
9
9
  import { registerProjectCommands } from './commands/projects.js';
10
10
  import { registerTeamCommands } from './commands/teams.js';
11
+ import { registerWebhookCommands } from './commands/webhooks.js';
11
12
  import { registerDemoCommands } from './commands/demo.js';
12
13
  import { registerFileCommands } from './commands/files.js';
13
14
  import { registerCompletionCommand } from './commands/completion.js';
@@ -66,6 +67,8 @@ export function createProgram<TClient extends ChassisClient<string>>(
66
67
  registerProjectCommands(kit, program, io);
67
68
  // The named groups of the workspace's people: teams *.
68
69
  registerTeamCommands(kit, program, io);
70
+ // The signed POSTs the workspace's events send to its URLs: webhooks *.
71
+ registerWebhookCommands(kit, program, io);
69
72
  // Demo links, as a signed-in owner (never a key): demo link, list, revoke.
70
73
  registerDemoCommands(kit, program, io);
71
74
  // The tool's own groups, where they have always sat in `--help`.
@@ -190,10 +190,11 @@ const COMMANDS = [
190
190
  'config',
191
191
  'workspaces',
192
192
  'workspace',
193
- // generic: the subgroups of the workspace, its named groups of people, then
194
- // the owner's demo links
193
+ // generic: the subgroups of the workspace, its named groups of people, its
194
+ // webhooks, then the owner's demo links
195
195
  'projects',
196
196
  'teams',
197
+ 'webhooks',
197
198
  'demo',
198
199
  // the tool's own group, at the seam
199
200
  'things',
@@ -9,7 +9,7 @@ import type { Command } from 'commander';
9
9
  * seam, so the tool's positional hook in the program is exercised on every
10
10
  * run. It imports nothing from a tool.
11
11
  */
12
- export type ThingsScope = 'things:read' | 'things:write' | 'things:export';
12
+ export type ThingsScope = 'things:read' | 'things:write' | 'things:export' | 'things:webhooks';
13
13
 
14
14
  export const VERSION = '0.0.1';
15
15
 
@@ -22,6 +22,7 @@ export const cli = defineCli({
22
22
  envPrefix: 'THINGS',
23
23
  keyPrefix: 'thk',
24
24
  exportScope: 'things:export',
25
+ webhooksScope: 'things:webhooks',
25
26
  cloudUrl: 'https://things.antasphere.test'
26
27
  },
27
28
  description: 'Command-line client for a Things instance',