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.
- package/CHANGELOG.md +42 -1
- package/README.md +50 -10
- package/dist/cli.d.ts +6 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +179 -16
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +2 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +37 -1
- package/dist/client.js.map +1 -1
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +31 -15
- package/dist/configure.js.map +1 -1
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +9 -1
- package/dist/credentials.js.map +1 -1
- package/dist/discover.d.ts +48 -0
- package/dist/discover.d.ts.map +1 -0
- package/dist/discover.js +106 -0
- package/dist/discover.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/projections.d.ts.map +1 -1
- package/dist/projections.js +15 -7
- package/dist/projections.js.map +1 -1
- package/dist/service.d.ts +2 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/storage.d.ts +5 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +6 -0
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +26 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/cli.md +67 -3
- package/package.json +1 -1
- package/src/cli.ts +228 -20
- package/src/client.ts +41 -1
- package/src/configure.ts +30 -14
- package/src/credentials.ts +9 -1
- package/src/discover.ts +155 -0
- package/src/index.ts +2 -0
- package/src/projections.ts +17 -7
- package/src/service.ts +8 -0
- package/src/storage.ts +9 -0
- 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:${
|
|
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
|
-
|
|
563
|
-
|
|
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
|
|
1250
|
-
.description('List
|
|
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
|
|
1254
|
-
|
|
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
|
|
1257
|
-
?
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
-
:
|
|
1264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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,
|
|
218
|
+
return { backend, home, serviceUrl, auth, credentialStore };
|
|
203
219
|
}
|
|
204
220
|
|
|
205
221
|
/** Reads an access key without ever accepting it as an argument. */
|
package/src/credentials.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/discover.ts
ADDED
|
@@ -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,
|