@lanes-sh/link 0.2.2 → 0.3.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/README.md +21 -8
- package/instructions/skills/lanes-link/SKILL.md +42 -14
- package/package.json +1 -1
- package/src/cli/argv.ts +50 -0
- package/src/cli/brand.ts +178 -0
- package/src/cli/callback-page.ts +108 -128
- package/src/cli/commands/connect/accounts.ts +5 -0
- package/src/cli/commands/connect/assertion.ts +187 -0
- package/src/cli/commands/connect/authorise.ts +61 -17
- package/src/cli/commands/connect/client.ts +37 -9
- package/src/cli/commands/connect/discover.ts +94 -0
- package/src/cli/commands/connect/family.ts +72 -0
- package/src/cli/commands/connect/index.ts +113 -115
- package/src/cli/commands/connect/method.ts +237 -0
- package/src/cli/commands/connect/outcome.ts +42 -1
- package/src/cli/commands/connect/pasted-token.ts +66 -0
- package/src/cli/commands/connect/requirements.ts +60 -8
- package/src/cli/commands/connect/setup.ts +16 -5
- package/src/cli/commands/connect/target-note.ts +34 -0
- package/src/cli/commands/identity.ts +258 -0
- package/src/cli/commands/knowledge/index.ts +390 -0
- package/src/cli/commands/knowledge/migrate.ts +180 -0
- package/src/cli/commands/knowledge/setup.ts +144 -0
- package/src/cli/commands/knowledge.ts +10 -0
- package/src/cli/commands/mcp/harnesses.ts +16 -2
- package/src/cli/commands/mcp/register.ts +9 -1
- package/src/cli/commands/mcp/stdio.ts +21 -0
- package/src/cli/commands/operate/dashboard.ts +107 -0
- package/src/cli/commands/operate/findings.ts +151 -0
- package/src/cli/commands/operate/inspect.ts +56 -158
- package/src/cli/commands/operate/outputs.ts +38 -11
- package/src/cli/commands/operate/serve.ts +3 -0
- package/src/cli/commands/operate/token.ts +1 -1
- package/src/cli/commands/operate.ts +2 -0
- package/src/cli/commands/profile/declare.ts +154 -0
- package/src/cli/commands/profile/removal.ts +17 -0
- package/src/cli/commands/profile.ts +83 -35
- package/src/cli/commands/setup.ts +22 -6
- package/src/cli/commands/target.ts +65 -83
- package/src/cli/config-edit.ts +48 -144
- package/src/cli/config-repair.ts +186 -0
- package/src/cli/dashboard-page.ts +284 -0
- package/src/cli/dashboard-shell.ts +125 -0
- package/src/cli/identity.ts +12 -1
- package/src/cli/main.ts +68 -4
- package/src/cli/oauth-callback.ts +187 -0
- package/src/cli/oauth-exchange.ts +57 -15
- package/src/cli/oauth.ts +67 -177
- package/src/cli/output.ts +21 -5
- package/src/cli/provider-marks.ts +45 -0
- package/src/cli/runtime/open.ts +67 -49
- package/src/cli/runtime/registry.ts +60 -2
- package/src/cli/runtime/select.ts +26 -13
- package/src/cli/runtime/vault.ts +61 -0
- package/src/cli/runtime.ts +2 -1
- package/src/cli/selection.ts +357 -0
- package/src/cli/usage.ts +32 -9
- package/src/connectivity/auth/README.md +7 -1
- package/src/connectivity/auth/basic/index.ts +1 -1
- package/src/connectivity/auth/index.ts +14 -0
- package/src/connectivity/auth/oauth-authcode/broker.ts +26 -0
- package/src/connectivity/auth/oauth-authcode/index.ts +16 -2
- package/src/connectivity/auth/oauth-authcode/provider.ts +1 -1
- package/src/connectivity/auth/oauth-authcode/refresh.ts +3 -3
- package/src/connectivity/auth/oauth-jwt/README.md +33 -0
- package/src/connectivity/auth/oauth-jwt/index.ts +237 -0
- package/src/connectivity/auth/oauth-jwt/key.ts +148 -0
- package/src/connectivity/auth/resolve.ts +1 -1
- package/src/connectivity/auth/token.ts +11 -0
- package/src/connectivity/index.ts +2 -0
- package/src/connectivity/manifest/auth.ts +99 -2
- package/src/connectivity/manifest/identity.ts +12 -0
- package/src/connectivity/manifest/index.ts +3 -1
- package/src/connectivity/manifest/provider.ts +37 -8
- package/src/connectivity/manifest/requirements.ts +109 -6
- package/src/deployments/adapters/filesystem.ts +10 -1
- package/src/deployments/adapters/github-api.ts +106 -0
- package/src/deployments/adapters/github-commit.ts +103 -0
- package/src/deployments/adapters/github-repo.ts +356 -0
- package/src/deployments/adapters/github-testing.ts +258 -0
- package/src/deployments/adapters/github.ts +125 -0
- package/src/deployments/deploy.ts +48 -19
- package/src/deployments/driver.ts +8 -1
- package/src/deployments/gcp/driver.ts +3 -1
- package/src/deployments/knowledge.ts +119 -0
- package/src/deployments/prepare.ts +2 -2
- package/src/deployments/servable.ts +81 -0
- package/src/deployments/target.ts +3 -2
- package/src/deployments/upload.ts +2 -1
- package/src/dispatch/dispatch.ts +1 -1
- package/src/profile/identity.ts +60 -0
- package/src/profile/index.ts +15 -5
- package/src/profile/knowledge.ts +124 -0
- package/src/profile/load.ts +17 -5
- package/src/profile/primitives.ts +24 -1
- package/src/profile/schema.ts +46 -3
- package/src/profile/targets.ts +74 -114
- package/src/profile/workspace.ts +71 -84
- package/src/providers/google/calendar/index.ts +2 -0
- package/src/providers/google/contacts/index.ts +2 -0
- package/src/providers/google/docs/index.ts +2 -0
- package/src/providers/google/drive/index.ts +2 -0
- package/src/providers/google/gmail/index.ts +2 -0
- package/src/providers/google/gmail-imap/index.ts +125 -0
- package/src/providers/google/index.ts +2 -1
- package/src/providers/google/shared/oauth.ts +18 -6
- package/src/providers/google/shared/service-account.ts +110 -0
- package/src/providers/google/shared/setup.ts +5 -2
- package/src/providers/google/sheets/index.ts +2 -0
- package/src/providers/google/tasks/index.ts +2 -0
- package/src/providers/identity/provider.ts +166 -0
- package/src/providers/index.ts +3 -0
- package/src/providers/owner.ts +10 -2
- package/src/providers/scopes.ts +2 -0
- package/src/providers/setup/plan.ts +31 -9
- package/src/providers/setup/provider.ts +23 -0
- package/src/providers/slack/index.ts +81 -33
- package/src/providers/slack/oauth.ts +103 -0
- package/src/providers/slack/scopes.ts +37 -0
- package/src/server/container.ts +18 -1
- package/src/server/cors.ts +252 -0
- package/src/server/dashboard.ts +208 -0
- package/src/server/endpoint.ts +45 -1
- package/src/server/generations.ts +11 -2
- package/src/server/harness.ts +7 -0
- package/src/server/index.ts +45 -6
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +28 -1
- package/src/server/mcp/visibility.ts +33 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { IdentityEntry, ProfileSelection, Resolution } from '#profile';
|
|
2
|
+
import { ConfigDocument } from '../config-edit.ts';
|
|
3
|
+
import { ensureIdentityConnection, repairLines, repaired } from '../config-repair.ts';
|
|
4
|
+
import { announce, announceProfile, emit, ok, print, style, table, warn } from '../output.ts';
|
|
5
|
+
import { resolveProfile, resolveProfileOnly, type GlobalFlags } from '../runtime.ts';
|
|
6
|
+
import { nextAfterEdit, publishProfileEdit } from '../publish.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `lanes link identity` — who this profile's owner is, for anything written as them.
|
|
10
|
+
*
|
|
11
|
+
* A control-plane command under ADR-007, and the reason is sharper here than for
|
|
12
|
+
* most of them: an agent able to edit this could edit the one fact that stops it
|
|
13
|
+
* signing as the wrong person. So the surface it feeds is read-only and the
|
|
14
|
+
* editing is here.
|
|
15
|
+
*
|
|
16
|
+
* Not to be confused with `src/cli/identity.ts`, which is about a *connection's*
|
|
17
|
+
* identity — which account it is, as the provider reports it at connect time.
|
|
18
|
+
* This is the owner's, as they declare it.
|
|
19
|
+
*
|
|
20
|
+
* Each command is a data function plus a printing wrapper, for the reason given
|
|
21
|
+
* in `commands/profile.ts`: `--json` needs the facts without the rendering.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** What `add` did, including anything it provisioned to make it readable. */
|
|
25
|
+
export interface IdentityAdded {
|
|
26
|
+
readonly profile: string;
|
|
27
|
+
readonly entry: IdentityEntry;
|
|
28
|
+
/** Config edits made so the surface is reachable at all. Usually empty. */
|
|
29
|
+
readonly provisioned: readonly string[];
|
|
30
|
+
readonly total: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface IdentityListing {
|
|
34
|
+
readonly profile: string;
|
|
35
|
+
readonly entries: readonly IdentityEntry[];
|
|
36
|
+
/** Whether an agent can read them, or only the file can. */
|
|
37
|
+
readonly reachable: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Whether a rule list puts `identity.*` in force, in either spelling. */
|
|
41
|
+
function covers(rules: ReadonlyArray<{ capability: string }>): boolean {
|
|
42
|
+
return rules.some((rule) => rule.capability === '*' || rule.capability === 'identity.*');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether anything but the file can read this profile's identity.
|
|
47
|
+
*
|
|
48
|
+
* Reported rather than assumed, because a profile hand-edited to hold an
|
|
49
|
+
* `identity` block and nothing else is a real state and a likely one — and
|
|
50
|
+
* listing the entries without saying they are unreachable would be the most
|
|
51
|
+
* misleading thing this command could print.
|
|
52
|
+
*/
|
|
53
|
+
function readable(config: {
|
|
54
|
+
connections: ReadonlyArray<{ provider: string }>;
|
|
55
|
+
policy: { allow: ReadonlyArray<{ capability: string }>; deny: ReadonlyArray<{ capability: string }> };
|
|
56
|
+
}): boolean {
|
|
57
|
+
return (
|
|
58
|
+
config.connections.some((connection) => connection.provider === 'identity') &&
|
|
59
|
+
covers(config.policy.allow) &&
|
|
60
|
+
!covers(config.policy.deny)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Declare one entry, and make sure something can read it.
|
|
66
|
+
*
|
|
67
|
+
* The provisioning is the part worth understanding. An `identity` block on its
|
|
68
|
+
* own is inert: `allowedConnections` finds no connection row for the provider
|
|
69
|
+
* and returns nothing *before* it consults policy, so the surface is absent from
|
|
70
|
+
* `tools/list` with nothing saying why — the same silent failure
|
|
71
|
+
* `ensureIdentityConnection` closes for `setup`. Writing the entry without it
|
|
72
|
+
* would leave an operator looking at a file that says exactly what they meant
|
|
73
|
+
* and an agent that cannot see a word of it.
|
|
74
|
+
*
|
|
75
|
+
* All three edits land in one `save()`, and that is not tidiness:
|
|
76
|
+
* `validateConfig` refuses a `policy.allow` rule naming a provider with no
|
|
77
|
+
* connection, so a run that wrote the rule and failed before the row would
|
|
78
|
+
* leave a profile that no longer loads. One save has no such middle.
|
|
79
|
+
*/
|
|
80
|
+
export async function addIdentity(
|
|
81
|
+
kind: string,
|
|
82
|
+
value: string,
|
|
83
|
+
options: { note?: string | undefined } & GlobalFlags,
|
|
84
|
+
): Promise<{ resolution: Resolution; added: IdentityAdded; published: string }> {
|
|
85
|
+
const { resolution, config, target } = await resolveProfile(options);
|
|
86
|
+
|
|
87
|
+
if (config.identity.some((entry) => entry.kind === kind && entry.value === value)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Profile "${resolution.profile}" already declares ${kind} "${value}".\n` +
|
|
90
|
+
` To change its note: lanes link identity remove ${kind} ${value}, then add it again.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
|
|
95
|
+
|
|
96
|
+
const entry: IdentityEntry = { kind, value, ...(options.note ? { note: options.note } : {}) };
|
|
97
|
+
document.addTo(['identity'], entry, { inline: true });
|
|
98
|
+
|
|
99
|
+
const repair = ensureIdentityConnection(document);
|
|
100
|
+
await document.save();
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
resolution,
|
|
104
|
+
added: {
|
|
105
|
+
profile: resolution.profile,
|
|
106
|
+
entry,
|
|
107
|
+
provisioned: repaired(repair) ? repairLines(repair) : [],
|
|
108
|
+
total: config.identity.length + 1,
|
|
109
|
+
},
|
|
110
|
+
published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read the block, without opening a target.
|
|
116
|
+
*
|
|
117
|
+
* `resolveProfileOnly`, deliberately: this reads one field of a YAML file that
|
|
118
|
+
* is declared once and applies to every target the profile has, so demanding
|
|
119
|
+
* `--target` would be the ceremony ADR-037 warns is how a required flag stops
|
|
120
|
+
* being a guard. `SELECTION` files it as `profile`, and this is the call that
|
|
121
|
+
* makes that true rather than merely stated.
|
|
122
|
+
*/
|
|
123
|
+
export async function readIdentity(
|
|
124
|
+
flags: GlobalFlags,
|
|
125
|
+
): Promise<{ selection: ProfileSelection; listing: IdentityListing }> {
|
|
126
|
+
const { selection, config } = await resolveProfileOnly(flags);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
selection,
|
|
130
|
+
listing: {
|
|
131
|
+
profile: selection.profile,
|
|
132
|
+
entries: config.identity,
|
|
133
|
+
reachable: readable(config),
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Drop one entry, leaving the connection row and the grant behind.
|
|
140
|
+
*
|
|
141
|
+
* Deliberately: removing the last entry would otherwise silently revoke the
|
|
142
|
+
* surface, and the next `identity add` would have to widen policy again. A
|
|
143
|
+
* command that quietly narrows what an agent may read, and a later one that
|
|
144
|
+
* quietly re-widens it, is worse than a surface that reports nothing declared.
|
|
145
|
+
*/
|
|
146
|
+
export async function removeIdentity(
|
|
147
|
+
kind: string,
|
|
148
|
+
value: string,
|
|
149
|
+
flags: GlobalFlags,
|
|
150
|
+
): Promise<{
|
|
151
|
+
resolution: Resolution;
|
|
152
|
+
removed: IdentityEntry;
|
|
153
|
+
remaining: number;
|
|
154
|
+
published: string;
|
|
155
|
+
}> {
|
|
156
|
+
const { resolution, config, target } = await resolveProfile(flags);
|
|
157
|
+
|
|
158
|
+
const index = config.identity.findIndex((entry) => entry.kind === kind && entry.value === value);
|
|
159
|
+
if (index === -1) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Profile "${resolution.profile}" does not declare ${kind} "${value}".\n` +
|
|
162
|
+
` Run: lanes link identity list`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const document = await ConfigDocument.open(resolution.workspaceRoot, resolution.profile);
|
|
167
|
+
document.removeFrom(['identity'], index);
|
|
168
|
+
await document.save();
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
resolution,
|
|
172
|
+
removed: config.identity[index] as IdentityEntry,
|
|
173
|
+
remaining: config.identity.length - 1,
|
|
174
|
+
published: nextAfterEdit(await publishProfileEdit({ resolution, config, target })),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function identityAdd(
|
|
179
|
+
kind: string,
|
|
180
|
+
value: string,
|
|
181
|
+
options: { note?: string | undefined; json?: boolean } & GlobalFlags,
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
const { resolution, added, published } = await addIdentity(kind, value, options);
|
|
184
|
+
|
|
185
|
+
return emit(options.json, added, () => {
|
|
186
|
+
announce(resolution);
|
|
187
|
+
print(ok(`${added.entry.kind} ${style.bold(added.entry.value)}`));
|
|
188
|
+
if (added.entry.note) print(` note ${added.entry.note}`);
|
|
189
|
+
|
|
190
|
+
// Named rather than folded into the success line: this widened what an
|
|
191
|
+
// agent may read, and a command that broadens a grant says which grant.
|
|
192
|
+
for (const line of added.provisioned) print(` ${style.dim(line)}`);
|
|
193
|
+
if (added.provisioned.length > 0) {
|
|
194
|
+
print(style.dim(' an agent can now read this profile’s identity'));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
print(style.dim(` ${published}`));
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function identityList(options: { json?: boolean } & GlobalFlags): Promise<void> {
|
|
202
|
+
const { selection, listing } = await readIdentity(options);
|
|
203
|
+
|
|
204
|
+
return emit(options.json, listing, () => {
|
|
205
|
+
announceProfile(selection);
|
|
206
|
+
|
|
207
|
+
if (listing.entries.length === 0) {
|
|
208
|
+
print(style.dim('This profile declares no identity.'));
|
|
209
|
+
print(
|
|
210
|
+
style.dim('Declare one with: lanes link identity add name "Your Name" --note "when to use it"'),
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Grouped by kind for the same reason the provider groups them: the mistake
|
|
216
|
+
// this exists to prevent is a name used where an address was wanted.
|
|
217
|
+
const kinds = [...new Set(listing.entries.map((entry) => entry.kind))];
|
|
218
|
+
const ordered = kinds.flatMap((kind) =>
|
|
219
|
+
listing.entries.filter((entry) => entry.kind === kind),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
table(
|
|
223
|
+
ordered.map((entry) => [
|
|
224
|
+
` ${style.dim(entry.kind)}`,
|
|
225
|
+
style.bold(entry.value),
|
|
226
|
+
entry.note ? style.dim(entry.note) : '',
|
|
227
|
+
]),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
if (!listing.reachable) {
|
|
231
|
+
print('');
|
|
232
|
+
print(warn('declared, but no agent can read it'));
|
|
233
|
+
print(
|
|
234
|
+
style.dim(
|
|
235
|
+
' the identity surface needs a connection row and an identity.* allow rule;',
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
print(style.dim(' adding an entry with this command writes both.'));
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function identityRemove(
|
|
244
|
+
kind: string,
|
|
245
|
+
value: string,
|
|
246
|
+
options: { json?: boolean } & GlobalFlags,
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
const { resolution, removed, remaining, published } = await removeIdentity(kind, value, options);
|
|
249
|
+
|
|
250
|
+
return emit(options.json, { removed, remaining }, () => {
|
|
251
|
+
announce(resolution);
|
|
252
|
+
print(ok(`removed ${removed.kind} ${style.bold(removed.value)}`));
|
|
253
|
+
if (remaining === 0) {
|
|
254
|
+
print(style.dim(' the surface stays granted, and now reports nothing declared'));
|
|
255
|
+
}
|
|
256
|
+
print(style.dim(` ${published}`));
|
|
257
|
+
});
|
|
258
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConfigError,
|
|
3
|
+
KNOWLEDGE_LAYOUT,
|
|
4
|
+
knowledgeTargetSchema,
|
|
5
|
+
layout,
|
|
6
|
+
parseRepository,
|
|
7
|
+
type Config,
|
|
8
|
+
type KnowledgeConfig,
|
|
9
|
+
} from '#profile';
|
|
10
|
+
import { describeKnowledge, type FetchLike } from '#deployments/knowledge.ts';
|
|
11
|
+
import { ConfigDocument } from '../../config-edit.ts';
|
|
12
|
+
import { announce, heading, ok, print, style, table } from '../../output.ts';
|
|
13
|
+
import { confirm, isInteractive } from '../../prompt.ts';
|
|
14
|
+
import type { BlobStore } from '#stores/blobs';
|
|
15
|
+
import { openRuntime, openSecretStoreFor, type GlobalFlags, type Runtime } from '../../runtime.ts';
|
|
16
|
+
import { probe, repositoryFor, resolveToken } from './setup.ts';
|
|
17
|
+
import {
|
|
18
|
+
collisions,
|
|
19
|
+
localContents,
|
|
20
|
+
moveIn,
|
|
21
|
+
moveOut,
|
|
22
|
+
printMovable,
|
|
23
|
+
removeLocal,
|
|
24
|
+
summarise,
|
|
25
|
+
} from './migrate.ts';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `lanes link knowledge` — where this profile's memory and skills are kept.
|
|
29
|
+
*
|
|
30
|
+
* Control plane, like every other command here: it writes a credential and
|
|
31
|
+
* edits config, both of which authorise future agent behaviour, so it
|
|
32
|
+
* originates outside the agent and has no MCP surface (ADR-007).
|
|
33
|
+
*
|
|
34
|
+
* The block it writes goes into **every** target the profile declares. A
|
|
35
|
+
* profile's memory is one thing wherever the profile happens to be running, and
|
|
36
|
+
* a `local` that reads a repository beside a `cloud` that reads a disk is two
|
|
37
|
+
* divergent sets of entries with nothing saying which is current. Each target
|
|
38
|
+
* still needs the token in its *own* credential store, which is what
|
|
39
|
+
* `lanes link secrets push` is for, and the command says so.
|
|
40
|
+
*
|
|
41
|
+
* ADR-041.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export interface KnowledgeFlags extends GlobalFlags {
|
|
45
|
+
readonly repo?: string | undefined;
|
|
46
|
+
readonly branch?: string | undefined;
|
|
47
|
+
readonly path?: string | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Move what is already stored, rather than leaving it behind.
|
|
50
|
+
*
|
|
51
|
+
* Three states, not two. `undefined` means nobody said, which is a question
|
|
52
|
+
* worth asking rather than an answer worth guessing — so `--migrate` and
|
|
53
|
+
* `--no-migrate` are both flags, and a run with neither and nothing to move
|
|
54
|
+
* proceeds silently.
|
|
55
|
+
*/
|
|
56
|
+
readonly migrate?: boolean | undefined;
|
|
57
|
+
/** Copy rather than move: verify the repository, then leave the local copy. */
|
|
58
|
+
readonly keep?: boolean | undefined;
|
|
59
|
+
readonly allowPublic?: boolean | undefined;
|
|
60
|
+
/** Ask for the token again even though one is stored. */
|
|
61
|
+
readonly replace?: boolean | undefined;
|
|
62
|
+
readonly yes?: boolean | undefined;
|
|
63
|
+
readonly json?: boolean | undefined;
|
|
64
|
+
/** Injected for tests. The repository is the only thing this command reaches. */
|
|
65
|
+
readonly fetch?: FetchLike | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function knowledgeShow(flags: KnowledgeFlags): Promise<void> {
|
|
69
|
+
const runtime = await openRuntime(flags, { fetch: flags.fetch });
|
|
70
|
+
try {
|
|
71
|
+
if (!flags.json) announce(runtime.resolution);
|
|
72
|
+
|
|
73
|
+
const profile = runtime.config.instance.profile;
|
|
74
|
+
const selection = ` --profile ${runtime.resolution.profile} --target ${runtime.target}`;
|
|
75
|
+
const skills = (await runtime.skills.list()).length;
|
|
76
|
+
const memory = (await runtime.storage.list(`${KNOWLEDGE_LAYOUT.memory}/`)).length;
|
|
77
|
+
const where = runtime.knowledge?.describe;
|
|
78
|
+
|
|
79
|
+
if (flags.json) {
|
|
80
|
+
print(JSON.stringify({ target: runtime.target, where: where ?? 'local', memory, skills }, null, 2));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
heading('Knowledge');
|
|
85
|
+
table([
|
|
86
|
+
// The memory *directory*, not the blob root it sits in. `layout.blobs`
|
|
87
|
+
// is `data/<profile>`, which is where every provider's namespace lives —
|
|
88
|
+
// printing it here would name a directory that is mostly not memory.
|
|
89
|
+
[
|
|
90
|
+
' memory',
|
|
91
|
+
where ? `${where}/${KNOWLEDGE_LAYOUT.memory}` : `${layout.blobs(profile)}/${KNOWLEDGE_LAYOUT.memory}`,
|
|
92
|
+
style.dim(`${memory} file${memory === 1 ? '' : 's'}`),
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
' skills',
|
|
96
|
+
where ? `${where}/${KNOWLEDGE_LAYOUT.skills}` : layout.skills(profile),
|
|
97
|
+
style.dim(`${skills} file${skills === 1 ? '' : 's'}`),
|
|
98
|
+
],
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
print('');
|
|
102
|
+
print(
|
|
103
|
+
style.dim(
|
|
104
|
+
` The vault, the credential store, runtime state and the audit log stay in target "${runtime.target}".`,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
// Complete commands, not shapes. Every one of these is pasted, and with
|
|
108
|
+
// nothing left to fall back on (ADR-037) a line missing either flag is a
|
|
109
|
+
// line that refuses — `emitted.test.ts` states the rule for the templates
|
|
110
|
+
// reachable without a runtime, and this is the same rule where there is one.
|
|
111
|
+
print(
|
|
112
|
+
style.dim(
|
|
113
|
+
where
|
|
114
|
+
? ` Bring them back with: lanes link knowledge use local --migrate${selection}`
|
|
115
|
+
: ` Keep them in a repository with: lanes link knowledge use github --repo <owner/name>${selection}`,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
} finally {
|
|
119
|
+
await runtime.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function knowledgeUse(where: string | undefined, flags: KnowledgeFlags): Promise<void> {
|
|
124
|
+
if (where === 'local') return useLocal(flags);
|
|
125
|
+
if (where === 'github') return useGithub(flags);
|
|
126
|
+
|
|
127
|
+
throw new ConfigError(
|
|
128
|
+
'Usage: lanes link knowledge use github --repo <owner/name> [--branch b] [--path p] [--migrate]\n' +
|
|
129
|
+
' lanes link knowledge use local [--migrate]',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function useGithub(flags: KnowledgeFlags): Promise<void> {
|
|
134
|
+
if (!flags.repo) {
|
|
135
|
+
throw new ConfigError(
|
|
136
|
+
'Which repository? lanes link knowledge use github --repo <owner/name>\n' +
|
|
137
|
+
' Make it private — memory entries are your own notes, and a skill names the accounts it operates on.',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const repo = parseRepository(flags.repo);
|
|
142
|
+
if (!repo) {
|
|
143
|
+
throw new ConfigError(
|
|
144
|
+
`"${flags.repo}" is not a repository. Give it as owner/name, or paste the URL of one.`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const knowledge = knowledgeTargetSchema.parse({
|
|
149
|
+
adapter: 'github',
|
|
150
|
+
repo,
|
|
151
|
+
...(flags.branch ? { branch: flags.branch } : {}),
|
|
152
|
+
...(flags.path ? { path: flags.path } : {}),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const runtime = await openRuntime(flags, { fetch: flags.fetch });
|
|
156
|
+
try {
|
|
157
|
+
announce(runtime.resolution);
|
|
158
|
+
const selection = ` --profile ${runtime.resolution.profile} --target ${runtime.target}`;
|
|
159
|
+
|
|
160
|
+
if (runtime.knowledge) {
|
|
161
|
+
print(style.dim(` This profile already reads ${runtime.knowledge.describe}.`));
|
|
162
|
+
print(
|
|
163
|
+
style.dim(
|
|
164
|
+
' Bring it back first: lanes link knowledge use local --migrate' +
|
|
165
|
+
` --profile ${runtime.resolution.profile} --target ${runtime.target}`,
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
throw new ConfigError('Already storing knowledge in a repository.');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Nothing is written until this returns: the token is asked for, the
|
|
172
|
+
// repository is probed, and either can refuse while the profile is intact.
|
|
173
|
+
const secrets = await openSecretStoreFor(
|
|
174
|
+
runtime.config,
|
|
175
|
+
runtime.resolution.workspaceRoot,
|
|
176
|
+
runtime.target,
|
|
177
|
+
);
|
|
178
|
+
const token = await resolveToken(secrets, knowledge.token_ref, knowledge.repo, selection, {
|
|
179
|
+
replace: flags.replace,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const repository = repositoryFor(knowledge, token, flags.fetch);
|
|
183
|
+
const { facts, viewer } = await probe(repository, { allowPublic: flags.allowPublic });
|
|
184
|
+
|
|
185
|
+
heading(describeKnowledge(knowledge));
|
|
186
|
+
table([
|
|
187
|
+
[' repository', facts.fullName, style.dim(facts.private ? 'private' : style.yellow('public'))],
|
|
188
|
+
[' branch', knowledge.branch ?? facts.defaultBranch, style.dim(facts.empty ? 'no commits yet' : '')],
|
|
189
|
+
[' token', viewer, style.dim('can write')],
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
const movable = await localContents(runtime.storage, runtime.skills, knowledge);
|
|
193
|
+
const moving = await decideMigration(movable.length > 0, flags);
|
|
194
|
+
|
|
195
|
+
if (moving) {
|
|
196
|
+
const overlap = await collisions(repository, movable);
|
|
197
|
+
if (overlap.length > 0 && !(await agreedTo(flags, `${overlap.length} file(s) already in ${facts.fullName} would be replaced. Continue?`))) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
print('');
|
|
202
|
+
print(` Moving ${summarise(movable)} into ${facts.fullName}:`);
|
|
203
|
+
printMovable(movable);
|
|
204
|
+
|
|
205
|
+
await moveIn(repository, movable, `Store this profile's memory and skills`);
|
|
206
|
+
print(ok(`committed ${movable.length} file${movable.length === 1 ? '' : 's'}, and read them back`));
|
|
207
|
+
|
|
208
|
+
if (flags.keep) {
|
|
209
|
+
print(style.dim(' --keep: the local copies are still there, and are no longer read.'));
|
|
210
|
+
} else {
|
|
211
|
+
await removeLocal(runtime.storage, runtime.skills, movable);
|
|
212
|
+
print(ok('removed the local copies'));
|
|
213
|
+
}
|
|
214
|
+
} else if (movable.length > 0) {
|
|
215
|
+
print('');
|
|
216
|
+
print(
|
|
217
|
+
style.dim(
|
|
218
|
+
` ${summarise(movable)} stay on disk and stop being read. Move them with --migrate.`,
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Last, deliberately, and in this order. Everything above can fail and leave
|
|
224
|
+
// a profile that still works; from here the config points at the repository.
|
|
225
|
+
// The token goes in first because the failure directions are not symmetric:
|
|
226
|
+
// a stored token nothing references is inert, and a config referencing a
|
|
227
|
+
// token that was never stored is a profile that cannot read its own memory.
|
|
228
|
+
await secrets.set(knowledge.token_ref, token);
|
|
229
|
+
await writeBlock(runtime.config, runtime.resolution.workspaceRoot, knowledge);
|
|
230
|
+
|
|
231
|
+
print('');
|
|
232
|
+
print(ok(`memory and skills are now kept in ${facts.fullName}`));
|
|
233
|
+
reportOtherTargets(runtime.config, runtime.target, knowledge.token_ref);
|
|
234
|
+
} finally {
|
|
235
|
+
await runtime.close();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function useLocal(flags: KnowledgeFlags): Promise<void> {
|
|
240
|
+
const runtime = await openRuntime(flags, { fetch: flags.fetch });
|
|
241
|
+
try {
|
|
242
|
+
announce(runtime.resolution);
|
|
243
|
+
const selection = ` --profile ${runtime.resolution.profile} --target ${runtime.target}`;
|
|
244
|
+
|
|
245
|
+
const knowledge = runtime.config.targets[runtime.target]?.knowledge;
|
|
246
|
+
if (!knowledge) {
|
|
247
|
+
print(style.dim(' This profile already keeps memory and skills on its own storage.'));
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const local = await openLocalStores(runtime);
|
|
252
|
+
|
|
253
|
+
// Built here rather than taken from `runtime.knowledge`, so both directions
|
|
254
|
+
// of this command reach the repository through one constructor.
|
|
255
|
+
const secrets = await openSecretStoreFor(
|
|
256
|
+
runtime.config,
|
|
257
|
+
runtime.resolution.workspaceRoot,
|
|
258
|
+
runtime.target,
|
|
259
|
+
);
|
|
260
|
+
const token = await resolveToken(secrets, knowledge.token_ref, knowledge.repo, selection);
|
|
261
|
+
const repository = repositoryFor(knowledge, token, flags.fetch);
|
|
262
|
+
|
|
263
|
+
if (
|
|
264
|
+
flags.migrate ??
|
|
265
|
+
(await agreedTo(flags, `Copy everything in ${knowledge.repo} back onto this target's storage?`))
|
|
266
|
+
) {
|
|
267
|
+
const moved = await moveOut(repository, knowledge, local.storage, local.skills);
|
|
268
|
+
print(
|
|
269
|
+
ok(
|
|
270
|
+
`wrote back ${moved.memory} memory entr${moved.memory === 1 ? 'y' : 'ies'} and ` +
|
|
271
|
+
`${moved.skills} skill file${moved.skills === 1 ? '' : 's'}`,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
await writeBlock(runtime.config, runtime.resolution.workspaceRoot, undefined);
|
|
277
|
+
|
|
278
|
+
print('');
|
|
279
|
+
print(ok('memory and skills are back on this target\'s own storage'));
|
|
280
|
+
print(
|
|
281
|
+
style.dim(
|
|
282
|
+
` ${knowledge.repo} still holds everything — it is version control, so nothing was removed from it.`,
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
print(style.dim(` The token at "${knowledge.token_ref}" is no longer used; remove it if you like.`));
|
|
286
|
+
} finally {
|
|
287
|
+
await runtime.close();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The profile's own stores, opened past whatever routing the runtime applied.
|
|
293
|
+
*
|
|
294
|
+
* `runtime.storage` and `runtime.skills` point at the repository once the block
|
|
295
|
+
* is declared, which is exactly wrong for a command whose job is to write the
|
|
296
|
+
* other way. This reaches the target's declared storage directly.
|
|
297
|
+
*/
|
|
298
|
+
async function openLocalStores(
|
|
299
|
+
runtime: Runtime,
|
|
300
|
+
): Promise<{ storage: BlobStore; skills: BlobStore }> {
|
|
301
|
+
const { openStorage } = await import('#deployments/target.ts');
|
|
302
|
+
const declared = runtime.config.targets[runtime.target];
|
|
303
|
+
if (!declared) throw new ConfigError(`Target "${runtime.target}" is not declared`);
|
|
304
|
+
|
|
305
|
+
const factory = await openStorage(
|
|
306
|
+
{
|
|
307
|
+
declared,
|
|
308
|
+
config: runtime.config,
|
|
309
|
+
root: runtime.resolution.workspaceRoot,
|
|
310
|
+
target: runtime.target,
|
|
311
|
+
},
|
|
312
|
+
runtime.credentials,
|
|
313
|
+
);
|
|
314
|
+
return { storage: factory(), skills: factory(layout.skills(runtime.config.instance.profile)) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Write, or remove, the block on every target this profile declares. */
|
|
318
|
+
async function writeBlock(
|
|
319
|
+
config: Config,
|
|
320
|
+
root: string,
|
|
321
|
+
knowledge: KnowledgeConfig | undefined,
|
|
322
|
+
): Promise<void> {
|
|
323
|
+
const document = await ConfigDocument.open(root, config.instance.profile);
|
|
324
|
+
|
|
325
|
+
for (const target of Object.keys(config.targets)) {
|
|
326
|
+
if (knowledge === undefined) {
|
|
327
|
+
document.removeIn(['targets', target, 'knowledge']);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
document.setIn(['targets', target, 'knowledge'], {
|
|
331
|
+
adapter: knowledge.adapter,
|
|
332
|
+
repo: knowledge.repo,
|
|
333
|
+
...(knowledge.branch ? { branch: knowledge.branch } : {}),
|
|
334
|
+
...(knowledge.path ? { path: knowledge.path } : {}),
|
|
335
|
+
token_ref: knowledge.token_ref,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
await document.save();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The other targets need the same token in their own credential stores.
|
|
344
|
+
*
|
|
345
|
+
* Not written for them: a second target's store is Secret Manager, which needs
|
|
346
|
+
* cloud credentials this command has no business assuming. `secrets push` is
|
|
347
|
+
* the command that already does exactly this, so this points at it.
|
|
348
|
+
*/
|
|
349
|
+
function reportOtherTargets(config: Config, current: string, ref: string): void {
|
|
350
|
+
const others = Object.keys(config.targets).filter((target) => target !== current);
|
|
351
|
+
if (others.length === 0) return;
|
|
352
|
+
|
|
353
|
+
const profile = config.instance.profile;
|
|
354
|
+
|
|
355
|
+
print(
|
|
356
|
+
style.dim(
|
|
357
|
+
` Written into every target (${others.join(', ')} as well). Each reads "${ref}" from its own credential store:`,
|
|
358
|
+
),
|
|
359
|
+
);
|
|
360
|
+
for (const target of others) {
|
|
361
|
+
print(style.dim(` lanes link secrets push --from ${current} --to ${target} --profile ${profile}`));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function decideMigration(hasContent: boolean, flags: KnowledgeFlags): Promise<boolean> {
|
|
366
|
+
if (flags.migrate !== undefined) return flags.migrate;
|
|
367
|
+
if (!hasContent) return false;
|
|
368
|
+
return agreedTo(flags, 'Move what is already stored into the repository?');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Ask, unless `--yes` already answered or there is nobody to ask.
|
|
373
|
+
*
|
|
374
|
+
* Refuses rather than assuming when there is no terminal: every question here
|
|
375
|
+
* precedes something that writes, and guessing at one of them is how a
|
|
376
|
+
* scripted run migrates a profile nobody asked it to.
|
|
377
|
+
*/
|
|
378
|
+
async function agreedTo(flags: KnowledgeFlags, question: string): Promise<boolean> {
|
|
379
|
+
if (flags.yes) return true;
|
|
380
|
+
if (!isInteractive()) {
|
|
381
|
+
throw new ConfigError(
|
|
382
|
+
`${question} — stdin is not a terminal, so there is nobody to ask.\n` +
|
|
383
|
+
' Pass --migrate to move what is stored, --no-migrate to leave it, or --yes to accept every question.',
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const yes = await confirm(question, true);
|
|
388
|
+
if (!yes) print(style.dim(' cancelled'));
|
|
389
|
+
return yes;
|
|
390
|
+
}
|