@lanes-sh/link 0.2.1 → 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 +59 -14
- package/package.json +1 -1
- package/src/auth/index.ts +3 -1
- package/src/auth/oauth/metadata.ts +83 -9
- package/src/auth/oauth/redirects.ts +70 -0
- package/src/auth/oauth/server.ts +49 -69
- package/src/auth/oauth/store.ts +19 -5
- 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 +9 -1
- package/src/deployments/gcp/survey.ts +3 -0
- 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/authorization.ts +13 -4
- 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 +72 -4
- 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 +57 -4
- package/src/server/generation.ts +1 -0
- package/src/server/generations.ts +13 -2
- package/src/server/harness.ts +20 -3
- package/src/server/index.ts +76 -12
- package/src/server/mcp/build.ts +1 -1
- package/src/server/mcp/index.ts +1 -0
- package/src/server/mcp/instructions.ts +62 -5
- package/src/server/mcp/visibility.ts +42 -0
- package/src/stores/blobs/route.ts +123 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { ConfigError, KNOWLEDGE_LAYOUT, knowledgeRoot, type KnowledgeConfig } from '#profile';
|
|
2
|
+
import type { BlobStore } from '#stores/blobs';
|
|
3
|
+
import { commitFiles, type CommitFile } from '#deployments/adapters/github-commit.ts';
|
|
4
|
+
import type { GithubRepository } from '#deployments/adapters/github-repo.ts';
|
|
5
|
+
import { print, style } from '../../output.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Moving what is already stored, in whichever direction.
|
|
9
|
+
*
|
|
10
|
+
* Four steps, and the order is the whole of the safety: **read, commit, verify,
|
|
11
|
+
* then delete.** A migration that deleted first, or that trusted a write it had
|
|
12
|
+
* not read back, would leave an owner with no memory and a plausible-looking
|
|
13
|
+
* success line. Nothing here deletes anything until the destination has been
|
|
14
|
+
* re-read and found to hold every byte that was sent.
|
|
15
|
+
*
|
|
16
|
+
* The write is **one commit**, through the Git Data API rather than one
|
|
17
|
+
* Contents call per file. Two hundred commits each reading "Store memory
|
|
18
|
+
* a-note.md" is not a history anybody opens, and it is two hundred round trips
|
|
19
|
+
* against a limit this endpoint now shares.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface Movable {
|
|
23
|
+
/** The key in the local store — `main/note.md`, or `triage/SKILL.md`. */
|
|
24
|
+
readonly key: string;
|
|
25
|
+
/** Where it goes in the repository. */
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly area: 'memory' | 'skills';
|
|
28
|
+
readonly data: Uint8Array;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Everything this profile has stored locally, ready to move.
|
|
33
|
+
*
|
|
34
|
+
* Memory is read from the profile's blob root under the provider's own
|
|
35
|
+
* namespace rather than from the connections the config declares. A connection
|
|
36
|
+
* removed from the file leaves its entries behind, and those entries are still
|
|
37
|
+
* the owner's — leaving them on a disk that is about to stop being consulted is
|
|
38
|
+
* how they would be lost without anything saying so.
|
|
39
|
+
*/
|
|
40
|
+
export async function localContents(
|
|
41
|
+
storage: BlobStore,
|
|
42
|
+
skills: BlobStore,
|
|
43
|
+
knowledge: KnowledgeConfig,
|
|
44
|
+
): Promise<Movable[]> {
|
|
45
|
+
const found: Movable[] = [];
|
|
46
|
+
const prefix = `${KNOWLEDGE_LAYOUT.memory}/`;
|
|
47
|
+
|
|
48
|
+
for (const entry of await storage.list(prefix)) {
|
|
49
|
+
const data = await storage.get(entry.key);
|
|
50
|
+
if (data === null) continue; // Listed then deleted; not worth failing over.
|
|
51
|
+
const key = entry.key.slice(prefix.length);
|
|
52
|
+
found.push({
|
|
53
|
+
key,
|
|
54
|
+
area: 'memory',
|
|
55
|
+
path: `${knowledgeRoot(knowledge, 'memory')}/${key}`,
|
|
56
|
+
data,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const entry of await skills.list()) {
|
|
61
|
+
const data = await skills.get(entry.key);
|
|
62
|
+
if (data === null) continue;
|
|
63
|
+
found.push({
|
|
64
|
+
key: entry.key,
|
|
65
|
+
area: 'skills',
|
|
66
|
+
path: `${knowledgeRoot(knowledge, 'skills')}/${entry.key}`,
|
|
67
|
+
data,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Paths the repository already holds that a migration would overwrite. */
|
|
75
|
+
export async function collisions(
|
|
76
|
+
repository: GithubRepository,
|
|
77
|
+
movable: readonly Movable[],
|
|
78
|
+
): Promise<string[]> {
|
|
79
|
+
const { entries } = await repository.entries();
|
|
80
|
+
return movable.filter((item) => entries.has(item.path)).map((item) => item.path);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Commit everything, then read it back before saying so.
|
|
85
|
+
*
|
|
86
|
+
* The verification is not ceremony. `commitFiles` reports what GitHub answered,
|
|
87
|
+
* and what this needs to know is what GitHub *stored* — which is a different
|
|
88
|
+
* question the moment a tree is built against a base that moved, or a blob
|
|
89
|
+
* upload succeeded and its tree entry did not. One extra request buys the right
|
|
90
|
+
* to delete the only other copy.
|
|
91
|
+
*/
|
|
92
|
+
export async function moveIn(
|
|
93
|
+
repository: GithubRepository,
|
|
94
|
+
movable: readonly Movable[],
|
|
95
|
+
message: string,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const files: CommitFile[] = movable.map((item) => ({ path: item.path, data: item.data }));
|
|
98
|
+
await commitFiles(repository, files, [], message);
|
|
99
|
+
|
|
100
|
+
const { entries } = await repository.entries();
|
|
101
|
+
const missing = movable.filter((item) => entries.get(item.path)?.size !== item.data.byteLength);
|
|
102
|
+
|
|
103
|
+
if (missing.length > 0) {
|
|
104
|
+
throw new ConfigError(
|
|
105
|
+
`The commit landed but ${missing.length} of ${movable.length} files did not read back ` +
|
|
106
|
+
`correctly (first: ${missing[0]?.path}). Nothing local has been deleted and the config ` +
|
|
107
|
+
'has not been changed, so this profile is exactly as it was. Try again.',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Remove what has been verified elsewhere. Never called before `moveIn`. */
|
|
113
|
+
export async function removeLocal(
|
|
114
|
+
storage: BlobStore,
|
|
115
|
+
skills: BlobStore,
|
|
116
|
+
movable: readonly Movable[],
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
for (const item of movable) {
|
|
119
|
+
if (item.area === 'memory') await storage.delete(`${KNOWLEDGE_LAYOUT.memory}/${item.key}`);
|
|
120
|
+
else await skills.delete(item.key);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The other direction: everything in the repository, written back to the
|
|
126
|
+
* profile's own storage.
|
|
127
|
+
*
|
|
128
|
+
* Nothing is deleted from the repository. It is version control — the history
|
|
129
|
+
* holds every entry regardless of what the tip says — so removing the files
|
|
130
|
+
* would buy no privacy and would throw away the copy somebody may still want.
|
|
131
|
+
* The command says so rather than deciding it silently.
|
|
132
|
+
*/
|
|
133
|
+
export async function moveOut(
|
|
134
|
+
repository: GithubRepository,
|
|
135
|
+
knowledge: KnowledgeConfig,
|
|
136
|
+
storage: BlobStore,
|
|
137
|
+
skills: BlobStore,
|
|
138
|
+
): Promise<{ memory: number; skills: number }> {
|
|
139
|
+
const { entries } = await repository.entries();
|
|
140
|
+
const roots = {
|
|
141
|
+
memory: `${knowledgeRoot(knowledge, 'memory')}/`,
|
|
142
|
+
skills: `${knowledgeRoot(knowledge, 'skills')}/`,
|
|
143
|
+
} as const;
|
|
144
|
+
|
|
145
|
+
const counts = { memory: 0, skills: 0 };
|
|
146
|
+
|
|
147
|
+
for (const entry of entries.values()) {
|
|
148
|
+
for (const area of ['memory', 'skills'] as const) {
|
|
149
|
+
if (!entry.path.startsWith(roots[area])) continue;
|
|
150
|
+
|
|
151
|
+
const key = entry.path.slice(roots[area].length);
|
|
152
|
+
const data = await repository.blob(entry.sha);
|
|
153
|
+
|
|
154
|
+
if (area === 'memory') await storage.put(`${KNOWLEDGE_LAYOUT.memory}/${key}`, data);
|
|
155
|
+
else await skills.put(key, data);
|
|
156
|
+
|
|
157
|
+
counts[area] += 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return counts;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** `12 skills and 47 memory entries`, or whichever half of that is non-zero. */
|
|
165
|
+
export function summarise(movable: readonly Movable[]): string {
|
|
166
|
+
const skills = movable.filter((item) => item.area === 'skills').length;
|
|
167
|
+
const memory = movable.length - skills;
|
|
168
|
+
|
|
169
|
+
const parts = [
|
|
170
|
+
...(skills > 0 ? [`${skills} skill${skills === 1 ? '' : 's'}`] : []),
|
|
171
|
+
...(memory > 0 ? [`${memory} memory entr${memory === 1 ? 'y' : 'ies'}`] : []),
|
|
172
|
+
];
|
|
173
|
+
return parts.length === 0 ? 'nothing' : parts.join(' and ');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function printMovable(movable: readonly Movable[]): void {
|
|
177
|
+
for (const item of movable) {
|
|
178
|
+
print(` ${style.green('→')} ${style.dim(`${item.path}`)}`);
|
|
179
|
+
}
|
|
180
|
+
}
|