synomem 0.4.0 → 0.5.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +50 -10
  3. package/dist/cli.d.ts +6 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +179 -16
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client.d.ts +2 -1
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +37 -1
  10. package/dist/client.js.map +1 -1
  11. package/dist/configure.d.ts.map +1 -1
  12. package/dist/configure.js +31 -15
  13. package/dist/configure.js.map +1 -1
  14. package/dist/credentials.d.ts.map +1 -1
  15. package/dist/credentials.js +9 -1
  16. package/dist/credentials.js.map +1 -1
  17. package/dist/discover.d.ts +48 -0
  18. package/dist/discover.d.ts.map +1 -0
  19. package/dist/discover.js +106 -0
  20. package/dist/discover.js.map +1 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/projections.d.ts.map +1 -1
  26. package/dist/projections.js +15 -7
  27. package/dist/projections.js.map +1 -1
  28. package/dist/service.d.ts +2 -1
  29. package/dist/service.d.ts.map +1 -1
  30. package/dist/storage.d.ts +5 -0
  31. package/dist/storage.d.ts.map +1 -1
  32. package/dist/storage.js +6 -0
  33. package/dist/storage.js.map +1 -1
  34. package/dist/types.d.ts +26 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/docs/cli.md +67 -3
  37. package/package.json +1 -1
  38. package/src/cli.ts +228 -20
  39. package/src/client.ts +41 -1
  40. package/src/configure.ts +30 -14
  41. package/src/credentials.ts +9 -1
  42. package/src/discover.ts +155 -0
  43. package/src/index.ts +2 -0
  44. package/src/projections.ts +17 -7
  45. package/src/service.ts +8 -0
  46. package/src/storage.ts +9 -0
  47. package/src/types.ts +21 -0
package/src/cli.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  type ConfigPlan,
22
22
  type CredentialStoreChoice,
23
23
  } from './configure.js';
24
+ import { discoverBoundWorkspace, discoverOrganizations, workspaceChoices } from './discover.js';
24
25
  import { defaultPromptIo, type PromptIo } from './prompt.js';
25
26
  import { credentialReference, OsCredentialStore, type CredentialStore } from './credentials.js';
26
27
  import { asSynomemError, SynomemError, type SynomemErrorCode } from './errors.js';
@@ -45,6 +46,7 @@ import {
45
46
  } from './skill-install.js';
46
47
  import type {
47
48
  ActorIdentity,
49
+ AgentRuntimeBinding,
48
50
  EvidenceReference,
49
51
  KudosListInput,
50
52
  KudosRecord,
@@ -74,6 +76,14 @@ export interface CliDependencies {
74
76
  reference: string;
75
77
  credentialStore: CredentialStore;
76
78
  }) => Promise<void>;
79
+ /*
80
+ * Injected so setup can be tested without a network. The default asks the
81
+ * service which workspace an access key is bound to.
82
+ */
83
+ discoverBoundWorkspace?: (options: {
84
+ baseUrl: string;
85
+ accessToken: string;
86
+ }) => Promise<{ workspaceId: string }>;
77
87
  createImportBundle?: (home: string) => Promise<ImportBundle>;
78
88
  remoteImport?: (options: {
79
89
  baseUrl: string;
@@ -300,6 +310,7 @@ export function createCli(
300
310
  const env = dependencies.env ?? process.env;
301
311
  const credentialStore = dependencies.credentialStore ?? new OsCredentialStore();
302
312
  const oauthLogin = dependencies.oauthLogin ?? loginWithOAuth;
313
+ const discoverWorkspace = dependencies.discoverBoundWorkspace ?? discoverBoundWorkspace;
303
314
  const promptIo = dependencies.promptIo ?? defaultPromptIo();
304
315
  const verifyRemoteCredential =
305
316
  dependencies.verifyRemoteCredential ??
@@ -340,6 +351,51 @@ export function createCli(
340
351
  .configureOutput({ writeOut: io.stdout, writeErr: io.stderr });
341
352
 
342
353
  const remoteCommand = program.command('remote').description('Administer a remote workspace');
354
+
355
+ /*
356
+ * The browser counterpart to an access key naming its own workspace.
357
+ *
358
+ * A signed-in account may reach several organizations, each with several
359
+ * workspaces, so there is a genuine choice to make -- and no way to make it
360
+ * without seeing the list. Printing the IDs alongside the names is the point:
361
+ * the ID is what `backend use remote --workspace` takes.
362
+ */
363
+ remoteCommand
364
+ .command('workspaces')
365
+ .description('List the organizations and workspaces this credential can reach')
366
+ .option('--url <url>', 'internal: alternate HTTPS origin')
367
+ .action(async (options: { url?: string }, command: Command) => {
368
+ const global = globals(command);
369
+ const config = readSynomemConfig(global.home, env);
370
+ const baseUrl =
371
+ options.url ??
372
+ (config?.backend.kind === 'remote' ? config.backend.baseUrl : cloudApiUrl(env));
373
+ const accessToken = env.SYNOMEM_ACCESS_TOKEN;
374
+ if (!accessToken) {
375
+ throw new SynomemError(
376
+ 'AUTH_REQUIRED',
377
+ 'Set SYNOMEM_ACCESS_TOKEN, or run `synomem auth login` first.',
378
+ );
379
+ }
380
+ const organizations = await discoverOrganizations({ baseUrl, accessToken });
381
+ const choices = workspaceChoices(organizations);
382
+ const human = organizations.length
383
+ ? organizations
384
+ .map((organization) =>
385
+ [
386
+ `${organization.displayName} (${organization.slug}) — ${organization.role}`,
387
+ ...(organization.workspaces.length
388
+ ? organization.workspaces.map(
389
+ (workspace) => ` ${workspace.id} ${workspace.displayName}`,
390
+ )
391
+ : [' (no workspaces yet)']),
392
+ ].join('\n'),
393
+ )
394
+ .join('\n')
395
+ : 'This account belongs to no organizations yet.';
396
+ output(io, global.json, { organizations, choices }, human);
397
+ });
398
+
343
399
  remoteCommand
344
400
  .command('import')
345
401
  .description('Preview or confirm a one-way import from a local Synomem home')
@@ -452,14 +508,53 @@ export function createCli(
452
508
  global: { home?: string; json: boolean },
453
509
  ): Promise<void> => {
454
510
  const home = plan.home;
511
+ const serviceUrl = plan.serviceUrl ?? cloudApiUrl(env);
512
+
513
+ /*
514
+ * The workspace is discovered, not typed.
515
+ *
516
+ * An installation access key is bound to exactly one workspace, so the
517
+ * service can be asked which one rather than the person. An explicit
518
+ * --workspace still wins, because automation should not depend on a
519
+ * network round trip to configure a machine.
520
+ */
521
+ let workspaceId = plan.workspaceId;
522
+ if (plan.backend === 'remote' && !workspaceId && token) {
523
+ const bound = await discoverWorkspace({ baseUrl: serviceUrl, accessToken: token });
524
+ workspaceId = bound.workspaceId;
525
+ io.stdout(`Access key is bound to workspace ${workspaceId}.\n`);
526
+ }
527
+ if (plan.backend === 'remote' && !workspaceId) {
528
+ /*
529
+ * Signing in through a browser needs an actor identity and a client ID,
530
+ * which is `synomem auth login`'s job. Rather than write a remote
531
+ * backend with no workspace -- a configuration that fails on its first
532
+ * real use -- say exactly what remains.
533
+ */
534
+ output(
535
+ io,
536
+ global.json,
537
+ { applied: false, pending: 'sign-in', home, serviceUrl },
538
+ [
539
+ '',
540
+ 'Nothing was configured yet: signing in through a browser is a separate step.',
541
+ '',
542
+ 'Run, with the actor this machine acts as:',
543
+ '',
544
+ ' synomem auth login --actor-id <agent> --client-id <client>',
545
+ '',
546
+ 'Then select the workspace it reports:',
547
+ '',
548
+ ' synomem backend use remote --workspace <workspace-id>',
549
+ ].join('\n'),
550
+ );
551
+ return;
552
+ }
553
+
455
554
  const config = writeSynomemBackend(
456
555
  plan.backend === 'local'
457
556
  ? { kind: 'local' }
458
- : {
459
- kind: 'remote',
460
- baseUrl: plan.serviceUrl ?? cloudApiUrl(env),
461
- workspaceId: plan.workspaceId!,
462
- },
557
+ : { kind: 'remote', baseUrl: serviceUrl, workspaceId: workspaceId! },
463
558
  home,
464
559
  );
465
560
 
@@ -474,7 +569,7 @@ export function createCli(
474
569
  // The platform store is the default, and a failure falls back to the
475
570
  // restricted file rather than leaving the credential nowhere.
476
571
  try {
477
- await credentialStore.set(`synomem:${plan.workspaceId}`, {
572
+ await credentialStore.set(`synomem:${workspaceId}`, {
478
573
  kind: 'installation-key',
479
574
  accessToken: token,
480
575
  });
@@ -559,8 +654,13 @@ export function createCli(
559
654
  throw new SynomemError('INVALID_INPUT', 'Pass --backend local or --backend remote.');
560
655
  }
561
656
  const backend: BackendChoice = options.backend;
562
- if (backend === 'remote' && !options.workspace) {
563
- throw new SynomemError('INVALID_INPUT', 'Remote setup requires --workspace.');
657
+ // An access key names its own workspace, so --workspace is only
658
+ // required when there is no key to ask.
659
+ if (backend === 'remote' && !options.workspace && !options.accessTokenStdin) {
660
+ throw new SynomemError(
661
+ 'INVALID_INPUT',
662
+ 'Remote setup requires --workspace, or --access-token-stdin so the key can name its own.',
663
+ );
564
664
  }
565
665
  const token = options.accessTokenStdin ? await readAccessToken(promptIo) : undefined;
566
666
  if (backend === 'remote' && options.auth === 'access-key' && !token) {
@@ -745,6 +845,92 @@ export function createCli(
745
845
  );
746
846
  });
747
847
 
848
+ /*
849
+ * `show` reads the config file; `status` proves the selection actually works.
850
+ *
851
+ * The two are deliberately separate. A person debugging a broken setup needs
852
+ * to know what is configured even when nothing can be reached, and a person
853
+ * checking that a setup is live needs a connection to have been made. One
854
+ * command doing both would make a printed workspace ID look like a reachable
855
+ * workspace.
856
+ */
857
+ backendCommand
858
+ .command('status')
859
+ .description('Connect to the selected backend and report what answered')
860
+ .action(async (_options, command: Command) => {
861
+ const global = globals(command);
862
+ const config = readSynomemConfig(global.home);
863
+ if (!config) {
864
+ throw new SynomemError(
865
+ 'CONFIG_INVALID',
866
+ 'No Synomem home here yet. Run `synomem config init` first.',
867
+ );
868
+ }
869
+ const result = await withClient(
870
+ global.home,
871
+ defaultActor(env, 'system', 'cli'),
872
+ async (client) => ({
873
+ info: await client.info(),
874
+ capabilities: await client.capabilities(),
875
+ diagnostics: (await client.doctor()).diagnostics.filter(
876
+ (item) => item.level === 'error' || item.level === 'warning',
877
+ ),
878
+ }),
879
+ );
880
+ const { info, capabilities, diagnostics } = result;
881
+ const where =
882
+ info.backend === 'local'
883
+ ? `Home: ${info.home}\nDatabase: ${info.databasePath}`
884
+ : `URL: ${info.baseUrl}`;
885
+ const problems = diagnostics.length
886
+ ? diagnostics
887
+ .map((item) => `${item.level.toUpperCase()} ${item.code}: ${item.message}`)
888
+ .join('\n')
889
+ : 'No warnings or errors.';
890
+ const human = [
891
+ `Backend: ${info.backend} (reachable)`,
892
+ where,
893
+ `Workspace: ${capabilities.binding.workspaceId}`,
894
+ `Acting as: ${capabilities.binding.actor.kind} ${capabilities.binding.actor.id}`,
895
+ problems,
896
+ ].join('\n');
897
+ output(io, global.json, { reachable: true, info, capabilities, diagnostics }, human);
898
+ if (diagnostics.some((item) => item.level === 'error')) cliExitCodes.set(program, 5);
899
+ });
900
+
901
+ const projectionCommand = program
902
+ .command('projection')
903
+ .description('Inspect the generated files Synomem derives from events');
904
+ projectionCommand
905
+ .command('status')
906
+ .description('Report whether the generated files match the canonical events')
907
+ .action(async (_options, command: Command) => {
908
+ const global = globals(command);
909
+ const status = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => {
910
+ if (!client.projectionStatus) {
911
+ throw new SynomemError(
912
+ 'INVALID_INPUT',
913
+ 'The remote backend keeps no filesystem projections, so there is nothing to report.',
914
+ );
915
+ }
916
+ return client.projectionStatus();
917
+ });
918
+ const enabled = Object.entries(status.settings)
919
+ .filter(([, on]) => on)
920
+ .map(([name]) => name);
921
+ const lines = [
922
+ `Directory: ${status.directory ?? '(none)'}`,
923
+ `Enabled: ${enabled.length ? enabled.join(', ') : 'none'}`,
924
+ `Last rebuilt: ${status.lastRebuiltAt ?? 'never'}`,
925
+ status.current
926
+ ? `Current: ${status.counts.manifest} generated file(s) match the events.`
927
+ : `Stale: ${status.counts.missing} missing, ${status.counts.unexpected} no longer expected. Run \`synomem rebuild\`.`,
928
+ ];
929
+ for (const path of status.missing) lines.push(` missing ${path}`);
930
+ for (const path of status.unexpected) lines.push(` unexpected ${path}`);
931
+ output(io, global.json, status, lines.join('\n'));
932
+ });
933
+
748
934
  const authCommand = program.command('auth').description('Inspect remote authentication');
749
935
  authCommand
750
936
  .command('status')
@@ -1245,23 +1431,45 @@ export function createCli(
1245
1431
  },
1246
1432
  );
1247
1433
 
1434
+ /*
1435
+ * With no agent named this answers the question people actually arrive with:
1436
+ * "where is any of my stuff running?". Naming an agent narrows it. Requiring
1437
+ * the agent, as this once did, means you must already know the answer to the
1438
+ * question you came to ask.
1439
+ */
1248
1440
  runtimeCommand
1249
- .command('list <agent>')
1250
- .description('List an agent runtime bindings')
1251
- .action(async (agent: string, _options, command: Command) => {
1441
+ .command('list [agent]')
1442
+ .description('List runtime bindings for one agent, or for every agent')
1443
+ .action(async (agent: string | undefined, _options, command: Command) => {
1252
1444
  const global = globals(command);
1253
- const bindings = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) =>
1254
- client.agents.bindings(agent),
1445
+ const result = await withClient(
1446
+ global.home,
1447
+ defaultActor(env, 'system', 'cli'),
1448
+ async (client) => {
1449
+ if (agent) {
1450
+ const profile = await client.agents.get(agent);
1451
+ return [{ profile, runtimeBindings: await client.agents.bindings(agent) }];
1452
+ }
1453
+ return (await client.agents.directory()).filter(
1454
+ (entry) => entry.runtimeBindings.length > 0,
1455
+ );
1456
+ },
1255
1457
  );
1256
- const human = bindings.length
1257
- ? bindings
1258
- .map(
1259
- (binding) =>
1260
- `${binding.id} ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} bound ${binding.boundAt}`,
1458
+ const describe = (binding: AgentRuntimeBinding) =>
1459
+ ` ${binding.id} ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} bound ${binding.boundAt}`;
1460
+ const human = result.length
1461
+ ? result
1462
+ .map((entry) =>
1463
+ [
1464
+ `${entry.profile.handle} (${entry.profile.id})`,
1465
+ ...entry.runtimeBindings.map(describe),
1466
+ ].join('\n'),
1261
1467
  )
1262
1468
  .join('\n')
1263
- : 'No runtime bindings.';
1264
- output(io, global.json, { bindings }, human);
1469
+ : agent
1470
+ ? 'No runtime bindings.'
1471
+ : 'No agent in this workspace has a runtime binding.';
1472
+ output(io, global.json, { agents: result }, human);
1265
1473
  });
1266
1474
 
1267
1475
  runtimeCommand
package/src/client.ts CHANGED
@@ -58,6 +58,7 @@ import type {
58
58
  UpdatePostInput,
59
59
  Diagnostic,
60
60
  DoctorResult,
61
+ ProjectionStatus,
61
62
  GiveKudosInput,
62
63
  GiveKudosResult,
63
64
  SendMemoInput,
@@ -1692,6 +1693,41 @@ export class SynomemClient extends SynomemCore implements SynomemService {
1692
1693
  this.projections = projections;
1693
1694
  }
1694
1695
 
1696
+ /*
1697
+ * Answers "would a rebuild change anything, and when did one last run?"
1698
+ *
1699
+ * The comparison is against the manifest rather than a directory walk, so a
1700
+ * file a person dropped into the projection tree by hand is not reported as
1701
+ * drift -- Synomem only claims authority over what it wrote.
1702
+ */
1703
+ async projectionStatus(): Promise<ProjectionStatus> {
1704
+ this.checkAbort();
1705
+ const expected = this.projections.expectedPaths();
1706
+ const entries = this.storage.projectionManifestEntries();
1707
+ const manifest = entries.map((entry) => entry.path);
1708
+ const inManifest = new Set(manifest);
1709
+ const inExpected = new Set(expected);
1710
+ const missing = expected.filter(
1711
+ (path) => !inManifest.has(path) || !existsSync(join(this.home, path)),
1712
+ );
1713
+ const unexpected = manifest.filter((path) => !inExpected.has(path));
1714
+ const limit = 20;
1715
+ return {
1716
+ directory: this.home,
1717
+ settings: { ...this.storage.config.projection },
1718
+ current: missing.length === 0 && unexpected.length === 0,
1719
+ ...(entries[0] ? { lastRebuiltAt: entries[0].generatedAt } : {}),
1720
+ counts: {
1721
+ expected: expected.length,
1722
+ manifest: manifest.length,
1723
+ missing: missing.length,
1724
+ unexpected: unexpected.length,
1725
+ },
1726
+ missing: missing.slice(0, limit),
1727
+ unexpected: unexpected.slice(0, limit),
1728
+ };
1729
+ }
1730
+
1695
1731
  async doctor(): Promise<DoctorResult> {
1696
1732
  this.checkAbort();
1697
1733
  const diagnostics: Diagnostic[] = [];
@@ -1791,7 +1827,11 @@ export class SynomemClient extends SynomemCore implements SynomemService {
1791
1827
  : 'Projection manifest is current.',
1792
1828
  });
1793
1829
  for (const profile of this.storage.listAgents()) {
1794
- const directory = join(this.home, profile.id);
1830
+ // Named by handle, which is what the projection writers create. Using
1831
+ // the canonical ID here checks a directory that does not exist, which
1832
+ // makes the check pass on a workspace whose agent directory really has
1833
+ // been replaced with a symbolic link.
1834
+ const directory = join(this.home, profile.handle);
1795
1835
  try {
1796
1836
  assertNoSymlinkEscape(this.home, directory);
1797
1837
  if (existsSync(directory) && lstatSync(directory).isSymbolicLink()) {
package/src/configure.ts CHANGED
@@ -51,10 +51,17 @@ export function credentialStoreChoices(
51
51
  platform === 'darwin'
52
52
  ? { value: 'keychain' as const, label: 'macOS Keychain', detail: 'Recommended.' }
53
53
  : platform === 'win32'
54
- ? {
55
- value: 'keychain' as const,
56
- label: 'Windows Credential Manager',
57
- detail: 'Recommended.',
54
+ ? /*
55
+ * Windows has no native option here yet, so the restricted file is
56
+ * the recommendation rather than Credential Manager. Offering a
57
+ * store the credential layer cannot actually read back would fail
58
+ * at the first use, after the wizard had already told the person
59
+ * their credential was safely stored.
60
+ */
61
+ {
62
+ value: 'file' as const,
63
+ label: 'A restricted file in the Synomem home',
64
+ detail: 'Recommended on Windows until Credential Manager support lands.',
58
65
  }
59
66
  : {
60
67
  value: 'keychain' as const,
@@ -63,11 +70,15 @@ export function credentialStoreChoices(
63
70
  };
64
71
  return [
65
72
  native,
66
- {
67
- value: 'file',
68
- label: 'A restricted file in the Synomem home',
69
- detail: 'Mode 0600. Use on headless machines with no keyring.',
70
- },
73
+ ...(native.value === 'file'
74
+ ? []
75
+ : [
76
+ {
77
+ value: 'file' as const,
78
+ label: 'A restricted file in the Synomem home',
79
+ detail: 'Mode 0600. Use on headless machines with no keyring.',
80
+ },
81
+ ]),
71
82
  {
72
83
  value: 'environment',
73
84
  label: 'Print environment-variable instructions',
@@ -185,10 +196,15 @@ export async function runConfigWizard(
185
196
  },
186
197
  ]);
187
198
 
188
- const workspaceId = await ask(io, 'Workspace ID');
189
- if (!workspaceId) {
190
- throw new SynomemError('INVALID_INPUT', 'A workspace ID is required for the hosted backend.');
191
- }
199
+ /*
200
+ * Deliberately no workspace prompt here.
201
+ *
202
+ * A hosted workspace ID looks like `ws-04psqx2rkt8ttft7a1t2z69r97`, and the
203
+ * credential authorized in the next step already knows which workspace it
204
+ * reaches -- an installation key is bound to exactly one, and a browser
205
+ * sign-in can list the ones the account belongs to. Asking first means
206
+ * asking a person to go and look something up that we are about to be told.
207
+ */
192
208
 
193
209
  const credentialStore =
194
210
  auth === 'access-key'
@@ -199,7 +215,7 @@ export async function runConfigWizard(
199
215
  )
200
216
  : 'auto';
201
217
 
202
- return { backend, home, serviceUrl, auth, workspaceId, credentialStore };
218
+ return { backend, home, serviceUrl, auth, credentialStore };
203
219
  }
204
220
 
205
221
  /** Reads an access key without ever accepting it as an argument. */
@@ -200,10 +200,18 @@ export class OsCredentialStore implements CredentialStore {
200
200
  return result.code === 0;
201
201
  }
202
202
 
203
+ /*
204
+ * Windows Credential Manager is not implemented yet. `cmdkey` can write a
205
+ * generic credential but deliberately will not read the secret back, so a
206
+ * store built on it would accept a credential and then never return it --
207
+ * worse than saying so plainly.
208
+ */
203
209
  private unsupported(): never {
204
210
  throw new SynomemError(
205
211
  'CONFIG_INVALID',
206
- 'Interactive credential storage is currently supported on macOS and Linux with secret-tool.',
212
+ this.platform === 'win32'
213
+ ? 'Windows Credential Manager storage is not supported yet. Run `synomem config init` and choose the restricted-file store, or set SYNOMEM_ACCESS_TOKEN.'
214
+ : 'Operating-system credential storage needs the macOS Keychain, or secret-tool on Linux. Run `synomem config init` and choose the restricted-file store, or set SYNOMEM_ACCESS_TOKEN.',
207
215
  );
208
216
  }
209
217
  }
@@ -0,0 +1,155 @@
1
+ /*
2
+ * Finding out which workspace a credential belongs to, so nobody has to type
3
+ * one from memory.
4
+ *
5
+ * A hosted workspace ID looks like `ws-04psqx2rkt8ttft7a1t2z69r97`. Asking a
6
+ * person to enter that during setup is asking them to leave and go find it, and
7
+ * the credential they just authorized already knows the answer -- or knows
8
+ * enough to offer a short list.
9
+ *
10
+ * Two credentials, two routes, because they carry different authority:
11
+ *
12
+ * An installation access key is bound to exactly one workspace. There is
13
+ * nothing to choose, so `discoverBoundWorkspace` reads it off the data plane
14
+ * and setup asks nothing at all.
15
+ *
16
+ * A browser sign-in authorizes an ACCOUNT, which may reach several
17
+ * organizations, each with several workspaces. `discoverOrganizations` lists
18
+ * them for selection.
19
+ */
20
+ import { SynomemError } from './errors.js';
21
+
22
+ export interface DiscoveredWorkspace {
23
+ id: string;
24
+ displayName: string;
25
+ }
26
+
27
+ export interface DiscoveredOrganization {
28
+ id: string;
29
+ slug: string;
30
+ displayName: string;
31
+ role: string;
32
+ workspaces: DiscoveredWorkspace[];
33
+ }
34
+
35
+ export interface DiscoveryOptions {
36
+ baseUrl: string;
37
+ accessToken: string;
38
+ /** Injected by tests. Defaults to the global fetch. */
39
+ fetch?: typeof globalThis.fetch;
40
+ signal?: AbortSignal;
41
+ }
42
+
43
+ interface Envelope<T> {
44
+ ok?: boolean;
45
+ data?: T;
46
+ error?: { code?: string; message?: string };
47
+ }
48
+
49
+ async function readJson<T>(options: DiscoveryOptions, path: string): Promise<T> {
50
+ const request = options.fetch ?? globalThis.fetch;
51
+ const url = new URL(
52
+ path,
53
+ options.baseUrl.endsWith('/') ? options.baseUrl : `${options.baseUrl}/`,
54
+ );
55
+ let response: Response;
56
+ try {
57
+ response = await request(url, {
58
+ headers: { authorization: `Bearer ${options.accessToken}`, accept: 'application/json' },
59
+ ...(options.signal ? { signal: options.signal } : {}),
60
+ });
61
+ } catch (error) {
62
+ throw new SynomemError(
63
+ 'REMOTE_UNAVAILABLE',
64
+ `Could not reach ${url.origin}: ${error instanceof Error ? error.message : String(error)}`,
65
+ );
66
+ }
67
+ let body: Envelope<T>;
68
+ try {
69
+ body = (await response.json()) as Envelope<T>;
70
+ } catch {
71
+ throw new SynomemError('REMOTE_PROTOCOL', `${url.pathname} did not return JSON.`);
72
+ }
73
+ if (!response.ok || body.ok !== true || body.data === undefined) {
74
+ // 401 and 403 are the ones a person can act on, so they keep their own
75
+ // codes rather than being flattened into a generic protocol error.
76
+ const code =
77
+ response.status === 401 ? 'AUTH_REQUIRED' : response.status === 403 ? 'AUTH_FORBIDDEN' : null;
78
+ const message = body.error?.message ?? `${url.pathname} returned ${response.status}.`;
79
+ throw new SynomemError(code ?? 'REMOTE_PROTOCOL', message);
80
+ }
81
+ return body.data;
82
+ }
83
+
84
+ /**
85
+ * The single workspace an installation access key can address.
86
+ *
87
+ * Answered by the data plane rather than the control plane: an installation key
88
+ * is not an account principal, so it cannot list organizations, but it can
89
+ * always say where it is bound.
90
+ */
91
+ export async function discoverBoundWorkspace(
92
+ options: DiscoveryOptions,
93
+ ): Promise<{ workspaceId: string; actor: { kind: string; id: string; displayName?: string } }> {
94
+ const identity = await readJson<{
95
+ workspaceId: string;
96
+ actor: { kind: string; id: string; displayName?: string };
97
+ }>(options, 'v1/identity');
98
+ if (!identity.workspaceId) {
99
+ throw new SynomemError('REMOTE_PROTOCOL', 'The service did not report a bound workspace.');
100
+ }
101
+ return { workspaceId: identity.workspaceId, actor: identity.actor };
102
+ }
103
+
104
+ /**
105
+ * Every organization this account belongs to, each with its workspaces.
106
+ *
107
+ * Organizations are listed even when they hold no workspaces yet, because
108
+ * "you belong to this organization and it is empty" is a different and more
109
+ * useful answer than omitting it and appearing to have found nothing.
110
+ */
111
+ export async function discoverOrganizations(
112
+ options: DiscoveryOptions,
113
+ ): Promise<DiscoveredOrganization[]> {
114
+ const me = await readJson<{
115
+ organizations: { id: string; slug: string; displayName: string; role: string }[];
116
+ }>(options, 'v1/me');
117
+ const organizations: DiscoveredOrganization[] = [];
118
+ for (const organization of me.organizations ?? []) {
119
+ const workspaces = await readJson<{ id: string; displayName: string }[]>(
120
+ options,
121
+ `v1/organizations/${encodeURIComponent(organization.id)}/workspaces`,
122
+ );
123
+ organizations.push({
124
+ id: organization.id,
125
+ slug: organization.slug,
126
+ displayName: organization.displayName,
127
+ role: organization.role,
128
+ workspaces: (workspaces ?? []).map((workspace) => ({
129
+ id: workspace.id,
130
+ displayName: workspace.displayName,
131
+ })),
132
+ });
133
+ }
134
+ return organizations;
135
+ }
136
+
137
+ /** Flattens discovery into the choices a person picks from. */
138
+ export function workspaceChoices(
139
+ organizations: DiscoveredOrganization[],
140
+ ): Array<{ value: string; label: string; detail?: string }> {
141
+ const choices: Array<{ value: string; label: string; detail?: string }> = [];
142
+ for (const organization of organizations) {
143
+ for (const workspace of organization.workspaces) {
144
+ choices.push({
145
+ value: workspace.id,
146
+ // The organization is part of the label, not the detail: two
147
+ // organizations may both have a workspace called "Production", and the
148
+ // label is the only part a person is guaranteed to read.
149
+ label: `${organization.displayName} / ${workspace.displayName}`,
150
+ detail: workspace.id,
151
+ });
152
+ }
153
+ }
154
+ return choices;
155
+ }
package/src/index.ts CHANGED
@@ -41,6 +41,8 @@ export type {
41
41
  } from './service.js';
42
42
  export { defaultConfig, resolveHome } from './config.js';
43
43
  export { cloudApiUrl, SYNOMEM_CLOUD_API_URL } from './cloud.js';
44
+ export { discoverBoundWorkspace, discoverOrganizations, workspaceChoices } from './discover.js';
45
+ export type { DiscoveredOrganization, DiscoveredWorkspace, DiscoveryOptions } from './discover.js';
44
46
  export { asSynomemError, errorCodes, SynomemError } from './errors.js';
45
47
  export {
46
48
  dueInstant,