@lanes-sh/link 0.3.0 → 0.3.2
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 +1 -0
- package/package.json +1 -1
- package/src/cli/commands/operate/inspect.ts +7 -6
- package/src/cli/commands/operate/outputs.ts +1 -1
- package/src/cli/commands/operate/policy.ts +7 -7
- package/src/cli/commands/operate/status.ts +108 -1
- package/src/cli/commands/profile/remove.ts +5 -5
- package/src/cli/commands/secrets.ts +6 -6
- package/src/cli/commands/sync.ts +262 -0
- package/src/cli/config-edit.ts +5 -0
- package/src/cli/dispatch-owner.ts +93 -0
- package/src/cli/main.ts +28 -63
- package/src/cli/nearest.ts +45 -0
- package/src/cli/runtime/open.ts +7 -2
- package/src/cli/selection.ts +55 -47
- package/src/cli/usage.ts +10 -2
- package/src/deployments/deploy.ts +57 -106
- package/src/deployments/discover.ts +103 -0
- package/src/deployments/prepare.ts +10 -4
- package/src/deployments/report.ts +117 -0
- package/src/deployments/servable.ts +3 -2
- package/src/deployments/serving.ts +165 -0
- package/src/deployments/sync-apply.ts +330 -0
- package/src/deployments/sync.ts +164 -0
- package/src/deployments/upload.ts +17 -11
- package/src/profile/deployments.ts +80 -0
- package/src/profile/index.ts +8 -0
- package/src/profile/schema.ts +36 -1
- package/src/profile/targets.ts +53 -0
- package/src/profile/workspace.ts +73 -0
- package/src/providers/google/shared/setup.ts +20 -5
- package/src/server/index.ts +1 -1
package/src/cli/main.ts
CHANGED
|
@@ -19,28 +19,15 @@ import {
|
|
|
19
19
|
} from './commands/operate.ts';
|
|
20
20
|
import { profileAdd, profileDefault, profileList } from './commands/profile.ts';
|
|
21
21
|
import { removeProfile as profileRemove } from './commands/profile/remove.ts';
|
|
22
|
+
import { syncTargets } from './commands/sync.ts';
|
|
22
23
|
import { targetList, targetShow, targetUse } from './commands/target.ts';
|
|
23
24
|
import { identityAdd, identityList, identityRemove } from './commands/identity.ts';
|
|
24
25
|
import { setupPlan } from './commands/setup.ts';
|
|
25
26
|
import { mcpAdd, mcpList, mcpStdio, skillDocument } from './commands/mcp.ts';
|
|
26
27
|
import { deploy } from '#deployments/deploy.ts';
|
|
27
28
|
import { secretsList, secretsPush, secretsSet } from './commands/secrets.ts';
|
|
28
|
-
import {
|
|
29
|
-
memoryForget,
|
|
30
|
-
memoryGet,
|
|
31
|
-
memoryList,
|
|
32
|
-
memoryWrite,
|
|
33
|
-
skillsAdd,
|
|
34
|
-
skillsList,
|
|
35
|
-
skillsRemove,
|
|
36
|
-
skillsShow,
|
|
37
|
-
vaultGet,
|
|
38
|
-
vaultKeyGenerate,
|
|
39
|
-
vaultList,
|
|
40
|
-
vaultRemove,
|
|
41
|
-
vaultSet,
|
|
42
|
-
} from './commands/owner.ts';
|
|
43
29
|
import { knowledgeShow, knowledgeUse } from './commands/knowledge.ts';
|
|
30
|
+
import { dispatchOwner } from './dispatch-owner.ts';
|
|
44
31
|
import { update } from './commands/update.ts';
|
|
45
32
|
import { all, globalFlags, knowledgeFlags, ownerFlags, parseArgv, text } from './argv.ts';
|
|
46
33
|
import { assertKnownFlags, requireSelection } from './selection.ts';
|
|
@@ -235,38 +222,17 @@ export async function run(argv: readonly string[]): Promise<void> {
|
|
|
235
222
|
if (second !== 'show' && second !== undefined) throw new Error(`Unknown: ${PROGRAM} config ${second}`);
|
|
236
223
|
return configShow(global);
|
|
237
224
|
|
|
225
|
+
// memory, skills and vault — one subject, dispatched together.
|
|
226
|
+
// `vault key generate` is synchronous, so this returns the result rather
|
|
227
|
+
// than testing it for truthiness.
|
|
238
228
|
case 'memory':
|
|
239
|
-
switch (second) {
|
|
240
|
-
case 'list':
|
|
241
|
-
case undefined:
|
|
242
|
-
return memoryList(owner);
|
|
243
|
-
case 'get':
|
|
244
|
-
return memoryGet(rest[0], owner);
|
|
245
|
-
case 'write':
|
|
246
|
-
return memoryWrite(rest[0], owner);
|
|
247
|
-
case 'forget':
|
|
248
|
-
return memoryForget(rest[0], owner);
|
|
249
|
-
default:
|
|
250
|
-
throw new Error(`Unknown: ${PROGRAM} memory ${second}`);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
229
|
case 'skills':
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
case undefined:
|
|
257
|
-
return skillsList(owner);
|
|
258
|
-
case 'show':
|
|
259
|
-
return skillsShow(rest[0], owner);
|
|
260
|
-
case 'add':
|
|
261
|
-
return skillsAdd(rest[0], owner);
|
|
262
|
-
case 'remove':
|
|
263
|
-
return skillsRemove(rest[0], owner);
|
|
264
|
-
default:
|
|
265
|
-
throw new Error(`Unknown: ${PROGRAM} skills ${second}`);
|
|
266
|
-
}
|
|
230
|
+
case 'vault':
|
|
231
|
+
return dispatchOwner(first, second, rest, owner, PROGRAM);
|
|
267
232
|
|
|
268
233
|
// Beside `memory` and `skills` because it is the question they raise next:
|
|
269
|
-
// those two say what is stored, and this says where it is kept.
|
|
234
|
+
// those two say what is stored, and this says where it is kept. Not one of
|
|
235
|
+
// them, though — it takes its own flags rather than the owner set.
|
|
270
236
|
case 'knowledge':
|
|
271
237
|
switch (second) {
|
|
272
238
|
case 'show':
|
|
@@ -278,26 +244,6 @@ export async function run(argv: readonly string[]): Promise<void> {
|
|
|
278
244
|
throw new Error(`Unknown: ${PROGRAM} knowledge ${second}`);
|
|
279
245
|
}
|
|
280
246
|
|
|
281
|
-
case 'vault':
|
|
282
|
-
switch (second) {
|
|
283
|
-
case 'list':
|
|
284
|
-
case undefined:
|
|
285
|
-
return vaultList(owner);
|
|
286
|
-
case 'get':
|
|
287
|
-
return vaultGet(rest[0], owner);
|
|
288
|
-
case 'set':
|
|
289
|
-
return vaultSet(rest[0], owner);
|
|
290
|
-
case 'remove':
|
|
291
|
-
return vaultRemove(rest[0], owner);
|
|
292
|
-
case 'key':
|
|
293
|
-
if (rest[0] !== 'generate') {
|
|
294
|
-
throw new Error(`Usage: ${PROGRAM} vault key generate`);
|
|
295
|
-
}
|
|
296
|
-
return vaultKeyGenerate(owner);
|
|
297
|
-
default:
|
|
298
|
-
throw new Error(`Unknown: ${PROGRAM} vault ${second}`);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
247
|
case 'check':
|
|
302
248
|
return check(global);
|
|
303
249
|
case 'plan':
|
|
@@ -365,6 +311,10 @@ export async function run(argv: readonly string[]): Promise<void> {
|
|
|
365
311
|
case 'deploy':
|
|
366
312
|
return deploy({
|
|
367
313
|
...global,
|
|
314
|
+
// Repeatable, like `profile add --target`: a deploy sends every profile
|
|
315
|
+
// that declares the target, and this narrows that set rather than
|
|
316
|
+
// selecting from it. The first named is the primary (ADR-043).
|
|
317
|
+
profiles: all(argv, 'profile'),
|
|
368
318
|
dryRun: flags['dry-run'] === true,
|
|
369
319
|
// `--iam` was a boolean that meant "add the platform's own check on top".
|
|
370
320
|
// It is now one of two values of a declared field, because the opposite
|
|
@@ -377,6 +327,21 @@ export async function run(argv: readonly string[]): Promise<void> {
|
|
|
377
327
|
nonInteractive: flags['non-interactive'] === true,
|
|
378
328
|
});
|
|
379
329
|
|
|
330
|
+
case 'sync':
|
|
331
|
+
switch (second) {
|
|
332
|
+
case undefined:
|
|
333
|
+
case 'targets':
|
|
334
|
+
return syncTargets({
|
|
335
|
+
...global,
|
|
336
|
+
dryRun: flags['dry-run'] === true,
|
|
337
|
+
from: text(flags, 'from'),
|
|
338
|
+
discover: flags['discover'] === true,
|
|
339
|
+
prefer: text(flags, 'prefer'),
|
|
340
|
+
});
|
|
341
|
+
default:
|
|
342
|
+
throw new Error(`Unknown: ${PROGRAM} sync ${second}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
380
345
|
case 'secrets':
|
|
381
346
|
switch (second) {
|
|
382
347
|
case 'push':
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Did you mean --profile?" — one edit away, and no further.
|
|
3
|
+
*
|
|
4
|
+
* Pulled out of `selection.ts`, which is the table of what each command needs
|
|
5
|
+
* and the checks that enforce it. Guessing at a misspelling is neither: it is
|
|
6
|
+
* string distance, it has no idea what a flag is, and the reason it lives
|
|
7
|
+
* beside that file rather than inside it is that the file kept growing every
|
|
8
|
+
* time a command was added and this never did.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The closest accepted flag, when there is an obviously close one.
|
|
13
|
+
*
|
|
14
|
+
* One edit away, or one transposition — enough for `--porfile` and `--taget`,
|
|
15
|
+
* and short of guessing at something the operator did not mean. A wrong guess
|
|
16
|
+
* here costs more than no guess: it sends them to a flag that is not the answer.
|
|
17
|
+
*/
|
|
18
|
+
export function nearest(given: string, allowed: ReadonlySet<string>): string | undefined {
|
|
19
|
+
for (const candidate of allowed) {
|
|
20
|
+
if (Math.abs(candidate.length - given.length) > 1) continue;
|
|
21
|
+
if (distance(given, candidate) <= 1) return candidate;
|
|
22
|
+
if (sorted(given) === sorted(candidate)) return candidate;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sorted = (text: string): string => [...text].sort().join('');
|
|
28
|
+
|
|
29
|
+
function distance(a: string, b: string): number {
|
|
30
|
+
let row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
31
|
+
|
|
32
|
+
for (let i = 1; i <= a.length; i++) {
|
|
33
|
+
const next = [i];
|
|
34
|
+
for (let j = 1; j <= b.length; j++) {
|
|
35
|
+
next[j] = Math.min(
|
|
36
|
+
row[j]! + 1,
|
|
37
|
+
next[j - 1]! + 1,
|
|
38
|
+
row[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
row = next;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return row[b.length]!;
|
|
45
|
+
}
|
package/src/cli/runtime/open.ts
CHANGED
|
@@ -6,10 +6,10 @@ import type { BlobStore } from '#stores/blobs';
|
|
|
6
6
|
import type { AnyConnector, ProviderManifest } from '#connectivity';
|
|
7
7
|
import { RateLimiter, allowedConnections } from '#policy';
|
|
8
8
|
import {
|
|
9
|
-
ConfigError,
|
|
10
9
|
KNOWLEDGE_LAYOUT,
|
|
11
10
|
layout,
|
|
12
11
|
listProfiles,
|
|
12
|
+
undeclaredTarget,
|
|
13
13
|
workspacePath,
|
|
14
14
|
type Config,
|
|
15
15
|
type Resolution,
|
|
@@ -125,7 +125,12 @@ export async function openRuntime(
|
|
|
125
125
|
): Promise<Runtime> {
|
|
126
126
|
const { resolution, config, target } = await resolveProfile(flags);
|
|
127
127
|
const declared = config.targets[target];
|
|
128
|
-
|
|
128
|
+
// `resolveProfile` has already checked this, so reaching it means a caller
|
|
129
|
+
// passed `allowUndeclaredTarget`. Through the shared refusal all the same:
|
|
130
|
+
// this was a fourth spelling of that sentence with the profile name and the
|
|
131
|
+
// list of what exists both missing, and the comment on `undeclaredTarget`
|
|
132
|
+
// says why one copy is the most that survives.
|
|
133
|
+
if (!declared) throw undeclaredTarget(target, config, resolution.profile);
|
|
129
134
|
|
|
130
135
|
const root = resolution.workspaceRoot;
|
|
131
136
|
const adapters: TargetInput = { declared, config, root, target };
|
package/src/cli/selection.ts
CHANGED
|
@@ -2,11 +2,15 @@ import {
|
|
|
2
2
|
ConfigError,
|
|
3
3
|
listProfiles,
|
|
4
4
|
loadProfileConfig,
|
|
5
|
+
loadWorkspaceProfiles,
|
|
5
6
|
noProfileNamed,
|
|
7
|
+
noTargetInWorkspace,
|
|
6
8
|
noTargetNamed,
|
|
7
9
|
resolveWorkspaceRoot,
|
|
10
|
+
targetsByName,
|
|
8
11
|
} from '#profile';
|
|
9
12
|
import type { Flags } from './argv.ts';
|
|
13
|
+
import { nearest } from './nearest.ts';
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* Which commands must name a profile and a target, and which flags each accepts.
|
|
@@ -30,8 +34,15 @@ import type { Flags } from './argv.ts';
|
|
|
30
34
|
* default to requiring nothing.
|
|
31
35
|
*/
|
|
32
36
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
37
|
+
/**
|
|
38
|
+
* What a command must be told before it can act.
|
|
39
|
+
*
|
|
40
|
+
* `target` is not a weaker `profile+target`. It says the command's subject *is*
|
|
41
|
+
* the target, and that the profiles behind it are every profile declaring it
|
|
42
|
+
* rather than one the operator picks (ADR-043). `--profile` stays accepted
|
|
43
|
+
* there, as a filter.
|
|
44
|
+
*/
|
|
45
|
+
export type Requires = 'none' | 'profile' | 'target' | 'profile+target';
|
|
35
46
|
|
|
36
47
|
/**
|
|
37
48
|
* The rule, per command path.
|
|
@@ -54,8 +65,17 @@ export type Requires = 'none' | 'profile' | 'profile+target';
|
|
|
54
65
|
* answer as input is circular, and it has to keep working in the state every
|
|
55
66
|
* other command fails in.
|
|
56
67
|
*
|
|
57
|
-
* `
|
|
58
|
-
* profile
|
|
68
|
+
* `status`, `deploy` and `sync targets` take `target` rather than
|
|
69
|
+
* `profile+target`. One deployed endpoint serves every profile in the workspace
|
|
70
|
+
* (ADR-009), so the profile set behind a target is enumerable from the config
|
|
71
|
+
* and naming one of them describes a slice, not the subject. That is not the
|
|
72
|
+
* inference ADR-037 removed: there is nothing to guess at, and nothing is
|
|
73
|
+
* silently chosen.
|
|
74
|
+
*
|
|
75
|
+
* `profile add` and `profile remove` **reject** `--profile`. Both name their
|
|
76
|
+
* profile positionally, so a flag naming a second one could only disagree with
|
|
77
|
+
* it. `add` has no profile to select before it exists; `remove` takes an
|
|
78
|
+
* optional `--target` to decommission one target's stores and keep the file.
|
|
59
79
|
*/
|
|
60
80
|
export const SELECTION: Record<string, Requires> = {
|
|
61
81
|
help: 'none',
|
|
@@ -83,7 +103,7 @@ export const SELECTION: Record<string, Requires> = {
|
|
|
83
103
|
'target list': 'profile',
|
|
84
104
|
'target show': 'profile',
|
|
85
105
|
'secrets push': 'profile',
|
|
86
|
-
'profile remove': '
|
|
106
|
+
'profile remove': 'none',
|
|
87
107
|
// Target-independent for the same reason `policy list` is: the block is
|
|
88
108
|
// declared once in the YAML and applies to every target the profile has.
|
|
89
109
|
identity: 'profile',
|
|
@@ -96,7 +116,8 @@ export const SELECTION: Record<string, Requires> = {
|
|
|
96
116
|
secrets: 'profile+target',
|
|
97
117
|
plan: 'profile+target',
|
|
98
118
|
doctor: 'profile+target',
|
|
99
|
-
|
|
119
|
+
// Target-scoped: see the note above. `--profile` narrows each to one profile.
|
|
120
|
+
status: 'target',
|
|
100
121
|
outputs: 'profile+target',
|
|
101
122
|
tools: 'profile+target',
|
|
102
123
|
// It reads which target it is rendering for before it decides anything: a
|
|
@@ -104,7 +125,11 @@ export const SELECTION: Record<string, Requires> = {
|
|
|
104
125
|
dashboard: 'profile+target',
|
|
105
126
|
attach: 'profile+target',
|
|
106
127
|
start: 'profile+target',
|
|
107
|
-
deploy: '
|
|
128
|
+
deploy: 'target',
|
|
129
|
+
// Both spellings: `sync` alone is `sync targets`, which is the only thing
|
|
130
|
+
// there is to sync, and naming it leaves room for the next one.
|
|
131
|
+
sync: 'target',
|
|
132
|
+
'sync targets': 'target',
|
|
108
133
|
'policy allow': 'profile+target',
|
|
109
134
|
'policy deny': 'profile+target',
|
|
110
135
|
// Both, unlike `identity list`, and for the same reason the policy edits are:
|
|
@@ -154,6 +179,7 @@ const SUBCOMMANDS: Record<string, readonly string[]> = {
|
|
|
154
179
|
mcp: ['skill', 'add', 'stdio', 'list'],
|
|
155
180
|
secrets: ['push', 'set', 'list'],
|
|
156
181
|
knowledge: ['show', 'use'],
|
|
182
|
+
sync: ['targets'],
|
|
157
183
|
};
|
|
158
184
|
|
|
159
185
|
/**
|
|
@@ -208,6 +234,15 @@ export async function requireSelection(
|
|
|
208
234
|
const needs = requirementFor(first, second);
|
|
209
235
|
if (needs === 'none') return;
|
|
210
236
|
|
|
237
|
+
// Asked before the profile requirement, because for these there is none. The
|
|
238
|
+
// refusal has to describe the workspace rather than one profile's targets,
|
|
239
|
+
// since the command was never going to act on only one.
|
|
240
|
+
if (needs === 'target') {
|
|
241
|
+
if (typeof flags['target'] === 'string') return;
|
|
242
|
+
const root = resolveWorkspaceRoot(env ? { env } : {});
|
|
243
|
+
throw noTargetInWorkspace(targetsByName(await loadWorkspaceProfiles(root)), root, env);
|
|
244
|
+
}
|
|
245
|
+
|
|
211
246
|
const profile = flags['profile'];
|
|
212
247
|
if (typeof profile !== 'string') {
|
|
213
248
|
const root = resolveWorkspaceRoot(env ? { env } : {});
|
|
@@ -258,7 +293,11 @@ const ACCEPTS: Record<string, readonly string[]> = {
|
|
|
258
293
|
],
|
|
259
294
|
setup: ['id'],
|
|
260
295
|
'profile add': ['target', 'non-interactive'],
|
|
261
|
-
'profile
|
|
296
|
+
// `--target` decommissions one target's stores and leaves the profile file in
|
|
297
|
+
// place (`removal.ts`). It is documented in `usage.ts` and read by
|
|
298
|
+
// `removalPlan`, and was refused here — the flag existed everywhere except in
|
|
299
|
+
// the list that decides whether it may be typed.
|
|
300
|
+
'profile remove': ['dry-run', 'yes', 'target'],
|
|
262
301
|
'target list': ['urls', 'target'],
|
|
263
302
|
'target show': ['target'],
|
|
264
303
|
'token show': ['show', 'raw'],
|
|
@@ -271,13 +310,16 @@ const ACCEPTS: Record<string, readonly string[]> = {
|
|
|
271
310
|
'mcp stdio': ['only'],
|
|
272
311
|
'mcp add': ['name', 'scope', 'token-env', 'dry-run', 'force', 'no-skill'],
|
|
273
312
|
'mcp skill': ['print', 'force'],
|
|
313
|
+
'mcp list': ['name', 'scope'],
|
|
274
314
|
dashboard: ['print'],
|
|
275
315
|
skill: ['print', 'force'],
|
|
276
316
|
deploy: ['dry-run', 'iam', 'access', 'service-account', 'tag', 'yes', 'non-interactive'],
|
|
277
317
|
'secrets push': ['from', 'to', 'overwrite', 'dry-run'],
|
|
318
|
+
sync: ['dry-run', 'from', 'discover', 'prefer'],
|
|
319
|
+
'sync targets': ['dry-run', 'from', 'discover', 'prefer'],
|
|
278
320
|
update: ['check'],
|
|
279
321
|
'identity add': ['note'],
|
|
280
|
-
memory: ['connection', 'title', 'description', 'file'],
|
|
322
|
+
memory: ['connection', 'title', 'description', 'file', 'tag'],
|
|
281
323
|
skills: ['connection', 'title', 'description', 'file'],
|
|
282
324
|
vault: ['connection'],
|
|
283
325
|
// `no-migrate` is listed beside `migrate` because they are three states
|
|
@@ -303,8 +345,10 @@ export function assertKnownFlags(first: string, second: string | undefined, flag
|
|
|
303
345
|
const allowed = new Set<string>([
|
|
304
346
|
...UNIVERSAL,
|
|
305
347
|
...(ACCEPTS[key] ?? []),
|
|
306
|
-
|
|
307
|
-
|
|
348
|
+
// `target` accepts both: the target is what it acts on, and `--profile`
|
|
349
|
+
// narrows it to one of the profiles behind it.
|
|
350
|
+
...(needs !== 'none' ? ['profile'] : []),
|
|
351
|
+
...(needs === 'target' || needs === 'profile+target' ? ['target'] : []),
|
|
308
352
|
]);
|
|
309
353
|
|
|
310
354
|
const named = [first, second].filter(Boolean).join(' ');
|
|
@@ -319,39 +363,3 @@ export function assertKnownFlags(first: string, second: string | undefined, flag
|
|
|
319
363
|
);
|
|
320
364
|
}
|
|
321
365
|
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* The closest accepted flag, when there is an obviously close one.
|
|
325
|
-
*
|
|
326
|
-
* One edit away, or one transposition — enough for `--porfile` and `--taget`,
|
|
327
|
-
* and short of guessing at something the operator did not mean. A wrong guess
|
|
328
|
-
* here costs more than no guess: it sends them to a flag that is not the answer.
|
|
329
|
-
*/
|
|
330
|
-
function nearest(given: string, allowed: ReadonlySet<string>): string | undefined {
|
|
331
|
-
for (const candidate of allowed) {
|
|
332
|
-
if (Math.abs(candidate.length - given.length) > 1) continue;
|
|
333
|
-
if (distance(given, candidate) <= 1) return candidate;
|
|
334
|
-
if (sorted(given) === sorted(candidate)) return candidate;
|
|
335
|
-
}
|
|
336
|
-
return undefined;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
const sorted = (text: string): string => [...text].sort().join('');
|
|
340
|
-
|
|
341
|
-
function distance(a: string, b: string): number {
|
|
342
|
-
let row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
343
|
-
|
|
344
|
-
for (let i = 1; i <= a.length; i++) {
|
|
345
|
-
const next = [i];
|
|
346
|
-
for (let j = 1; j <= b.length; j++) {
|
|
347
|
-
next[j] = Math.min(
|
|
348
|
-
row[j]! + 1,
|
|
349
|
-
next[j - 1]! + 1,
|
|
350
|
-
row[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
row = next;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
return row[b.length]!;
|
|
357
|
-
}
|
package/src/cli/usage.ts
CHANGED
|
@@ -49,6 +49,11 @@ ${style.bold('Profiles')}
|
|
|
49
49
|
${style.bold('Targets')}
|
|
50
50
|
${PROGRAM} target list [--urls] where this profile can run
|
|
51
51
|
${PROGRAM} target show <name> one target's adapters, and the address it answers on
|
|
52
|
+
${PROGRAM} sync targets --target t [--from gs://bucket] [--discover]
|
|
53
|
+
[--prefer local|remote] [--dry-run]
|
|
54
|
+
reconcile this workspace with the copy the
|
|
55
|
+
deployment reads; recovers a target a profile
|
|
56
|
+
has lost
|
|
52
57
|
|
|
53
58
|
${style.bold('Who you are')}
|
|
54
59
|
${PROGRAM} identity add <kind> <value> [--note text] [--json]
|
|
@@ -89,10 +94,13 @@ ${style.bold('Your own context')}
|
|
|
89
94
|
${PROGRAM} vault key generate a fresh LANES_LINK_VAULT_KEY, printed once
|
|
90
95
|
|
|
91
96
|
${style.bold('Deploying')}
|
|
92
|
-
${PROGRAM} deploy [--dry-run]
|
|
97
|
+
${PROGRAM} deploy --target t [--dry-run]
|
|
98
|
+
set up, build, and roll one revision serving
|
|
99
|
+
every profile that declares the target
|
|
100
|
+
${PROGRAM} deploy --target t --profile a --profile b
|
|
101
|
+
only these; the first owns the endpoint token
|
|
93
102
|
${PROGRAM} deploy --non-interactive take the stored answers, never prompt
|
|
94
103
|
${PROGRAM} deploy --access iam|public who gets past the platform's own door
|
|
95
|
-
${PROGRAM} deploy --target <name> deploy a second one, under its own name
|
|
96
104
|
${PROGRAM} secrets list credential references in this target
|
|
97
105
|
${PROGRAM} secrets set <ref> store one value, read from stdin
|
|
98
106
|
${PROGRAM} secrets push --from local --to cloud
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigError, type DeployConfig } from '#profile';
|
|
1
|
+
import { ConfigError, recordDeployment, resolveWorkspaceRoot, type DeployConfig } from '#profile';
|
|
2
2
|
import { announce, fail, heading, ok, print, style, warn } from '#cli/output.ts';
|
|
3
3
|
import { staleNudge } from '#cli/release.ts';
|
|
4
4
|
import { confirm, isInteractive } from '#cli/prompt.ts';
|
|
@@ -9,6 +9,8 @@ import { driverFor } from './drivers.ts';
|
|
|
9
9
|
import { prepareSecrets, readableRefs, rotatableRefs } from './prepare.ts';
|
|
10
10
|
import { deployedWorkspace, repairSetupSurface, uploadWorkspace } from './upload.ts';
|
|
11
11
|
import { unservableProfiles, unservableRefusal } from './servable.ts';
|
|
12
|
+
import { collidingRefs, collisionRefusal, servingProfiles } from './serving.ts';
|
|
13
|
+
import { healthLine, reachability, registerLine, reportUnauthorised } from './report.ts';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* `lanes link deploy` — set up what is missing, build the image, roll a revision,
|
|
@@ -42,6 +44,14 @@ export interface DeployFlags extends GlobalFlags {
|
|
|
42
44
|
* this is for a terminal attached to a job nobody is watching.
|
|
43
45
|
*/
|
|
44
46
|
readonly nonInteractive?: boolean | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Which profiles to send, narrowing the set the target implies.
|
|
49
|
+
*
|
|
50
|
+
* Repeatable, and absent means every profile declaring the target — the set
|
|
51
|
+
* the endpoint is going to open either way (ADR-043). The first one named is
|
|
52
|
+
* the primary.
|
|
53
|
+
*/
|
|
54
|
+
readonly profiles?: readonly string[] | undefined;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
export async function deploy(flags: DeployFlags): Promise<void> {
|
|
@@ -57,10 +67,34 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
57
67
|
// from one command line on the command that creates cloud resources and rolls
|
|
58
68
|
// a public URL — where `allowUndeclaredTarget` means a mistake is not refused
|
|
59
69
|
// but surveyed, written into the profile, and deployed as a new service.
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
//
|
|
71
|
+
// Which profiles is derived rather than named: every profile declaring the
|
|
72
|
+
// target, because that is the set the revision will try to open (ADR-009).
|
|
73
|
+
// `--profile` narrows it, and a first deploy still has to name one — a target
|
|
74
|
+
// nothing declares has no set to derive from.
|
|
75
|
+
const { profiles: serving, primary } = await servingProfiles({
|
|
76
|
+
workspaceRoot: resolveWorkspaceRoot(),
|
|
77
|
+
target: requireTargetFlag(flags),
|
|
78
|
+
named: flags.profiles ?? [],
|
|
62
79
|
});
|
|
80
|
+
|
|
81
|
+
const { resolution, config, target } = await resolveProfile(
|
|
82
|
+
{ ...flags, profile: primary },
|
|
83
|
+
{ allowUndeclaredTarget: true },
|
|
84
|
+
);
|
|
63
85
|
announce(resolution);
|
|
86
|
+
if (serving.length > 1) {
|
|
87
|
+
print(style.dim(` serving ${serving.join(', ')} — ${primary} owns the token`));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Before anything external, beside `check`, and for the same reason: two
|
|
91
|
+
// profiles writing one flat credential ref into one store is a failure with
|
|
92
|
+
// no later symptom worth having.
|
|
93
|
+
const colliding = await collidingRefs(resolution.workspaceRoot, serving);
|
|
94
|
+
if (colliding.length > 0) {
|
|
95
|
+
heading('Cannot share a credential store');
|
|
96
|
+
throw new ConfigError(collisionRefusal(colliding, target));
|
|
97
|
+
}
|
|
64
98
|
|
|
65
99
|
// `check` before anything external, per the gate order: a config that will be
|
|
66
100
|
// rejected on boot should be rejected here, not after a five-minute build.
|
|
@@ -91,8 +125,8 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
91
125
|
// below are, and read from config and manifests before anything opens a
|
|
92
126
|
// store — `--dry-run` must reach the printed step list without touching a
|
|
93
127
|
// credential.
|
|
94
|
-
const rotatable = await rotatableRefs(resolution.workspaceRoot,
|
|
95
|
-
const readable = await readableRefs(resolution.workspaceRoot,
|
|
128
|
+
const rotatable = await rotatableRefs(resolution.workspaceRoot, serving);
|
|
129
|
+
const readable = await readableRefs(resolution.workspaceRoot, serving, declared);
|
|
96
130
|
const provision = await driver.provision({
|
|
97
131
|
deploy: deployConfig,
|
|
98
132
|
declared,
|
|
@@ -212,7 +246,7 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
212
246
|
// says there is a bucket to send to at all.
|
|
213
247
|
const unservable = await unservableProfiles({
|
|
214
248
|
workspaceRoot: resolution.workspaceRoot,
|
|
215
|
-
|
|
249
|
+
profiles: serving,
|
|
216
250
|
target,
|
|
217
251
|
});
|
|
218
252
|
|
|
@@ -221,12 +255,22 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
221
255
|
throw new ConfigError(unservableRefusal(unservable, target));
|
|
222
256
|
}
|
|
223
257
|
|
|
224
|
-
await repairSetupSurface(resolution.workspaceRoot,
|
|
258
|
+
await repairSetupSurface(resolution.workspaceRoot, serving);
|
|
225
259
|
|
|
226
260
|
// Before the rollout, so the revision that comes up finds a config to read.
|
|
227
261
|
// Uploading after would leave a window where the service is serving and the
|
|
228
262
|
// workspace it was told to read is not there yet.
|
|
229
|
-
await uploadWorkspace(resolution.workspaceRoot, workspace,
|
|
263
|
+
await uploadWorkspace(resolution.workspaceRoot, workspace, serving);
|
|
264
|
+
|
|
265
|
+
// Recorded once the bucket is known to hold this target's config. It is an
|
|
266
|
+
// index, not configuration (ADR-044) — the next recovery reads it instead
|
|
267
|
+
// of asking the platform.
|
|
268
|
+
await recordDeployment(resolution.workspaceRoot, {
|
|
269
|
+
target,
|
|
270
|
+
workspace,
|
|
271
|
+
primary: resolution.profile,
|
|
272
|
+
last_deploy: new Date().toISOString(),
|
|
273
|
+
});
|
|
230
274
|
}
|
|
231
275
|
|
|
232
276
|
heading('Rolling out');
|
|
@@ -251,103 +295,10 @@ export async function deploy(flags: DeployFlags): Promise<void> {
|
|
|
251
295
|
reportUnauthorised(prepared.warnings, resolution.profile, target);
|
|
252
296
|
}
|
|
253
297
|
|
|
254
|
-
/**
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
* `tools/list` when it connects and keeps it: this endpoint is stateless, so
|
|
259
|
-
* there is no stream on which to send `notifications/tools/list_changed`, and
|
|
260
|
-
* `buildMcpServer` no longer pretends otherwise. A first deploy necessarily
|
|
261
|
-
* publishes a profile whose only connection is `setup.main` — the accounts come
|
|
262
|
-
* after — so a connector registered in that window captures a two-tool surface
|
|
263
|
-
* and holds it. The endpoint is right, every reload lands, and the client shows
|
|
264
|
-
* two tools until someone removes and re-adds it.
|
|
265
|
-
*
|
|
266
|
-
* Unconditional, and that is the correction that matters. This was gated on
|
|
267
|
-
* `prepared.warnings.length`, which is zero in precisely the case it describes:
|
|
268
|
-
* a fresh profile declares only `setup.main`, `setup` is a local provider with
|
|
269
|
-
* no credential, so `prepareSecrets` has nothing to warn about. The advice
|
|
270
|
-
* appeared only on a later re-deploy, by which point the connector is usually
|
|
271
|
-
* registered and the ordering is no longer available to get right.
|
|
272
|
-
*/
|
|
273
|
-
function registerLine(profile: string, target: string): string {
|
|
274
|
-
return style.dim(
|
|
275
|
-
` Connect your accounts first, then register with:\n` +
|
|
276
|
-
` lanes link outputs --profile ${profile} --target ${target}\n` +
|
|
277
|
-
' A client keeps the tool list it fetched when it connected, so one registered\n' +
|
|
278
|
-
' before the accounts holds a surface without them until it is re-added.',
|
|
279
|
-
);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* The accounts a browser still has to authorise, and the step after them.
|
|
284
|
-
*
|
|
285
|
-
* Printed last rather than before the build, because this is the only thing
|
|
286
|
-
* left to do and a list eight steps up the scrollback is a list nobody reads.
|
|
287
|
-
*
|
|
288
|
-
* There is no second deploy at the end of it any more, and the reason the old
|
|
289
|
-
* one existed is worth keeping. Connection *credentials* are read live on every
|
|
290
|
-
* call, so a fresh `connect` looked like it should be picked up — but whether a
|
|
291
|
-
* connection was usable at all was decided by a reconcile that ran once per
|
|
292
|
-
* process, so a revision that came up with an account unauthorised went on
|
|
293
|
-
* refusing it, naming the connection rather than the staleness. Reconcile now
|
|
294
|
-
* runs again on every reload, and `connect` asks for one (ADR-029).
|
|
295
|
-
*/
|
|
296
|
-
function reportUnauthorised(warnings: readonly string[], profile: string, target: string): void {
|
|
297
|
-
if (warnings.length === 0) return;
|
|
298
|
-
|
|
299
|
-
heading('Not authorised yet');
|
|
300
|
-
for (const problem of warnings) print(warn(problem));
|
|
301
|
-
print('');
|
|
302
|
-
print(
|
|
303
|
-
style.dim(
|
|
304
|
-
' A browser consent per account is the one step this cannot take for you:\n' +
|
|
305
|
-
` lanes link connect <provider> --profile ${profile} --target ${target}\n` +
|
|
306
|
-
' Each is served as soon as it is authorised. There is no second deploy —\n' +
|
|
307
|
-
' deploying is how code gets here, and authorising an account changes none.',
|
|
308
|
-
),
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Who can reach the service once this lands.
|
|
314
|
-
*
|
|
315
|
-
* Printed on every deploy rather than only when it changes, because it is the
|
|
316
|
-
* one property of a deployment that is invisible from the outside until someone
|
|
317
|
-
* either cannot get in or should not have been able to.
|
|
318
|
-
*/
|
|
319
|
-
function reachability(access: DeployConfig['access']): string {
|
|
320
|
-
return access === 'iam'
|
|
321
|
-
? style.dim(
|
|
322
|
-
' access: iam — the platform admits only callers holding its own identity\n' +
|
|
323
|
-
' token. No agent harness can mint one; use --access public with an\n' +
|
|
324
|
-
' authorization block if a remote MCP client has to reach this.',
|
|
325
|
-
)
|
|
326
|
-
: style.dim(
|
|
327
|
-
' access: public — the platform lets requests through and this endpoint\n' +
|
|
328
|
-
' authenticates them. The bearer token is what protects it.',
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/** Ask the deployed endpoint whether it came up. */
|
|
333
|
-
async function healthLine(url: string): Promise<string> {
|
|
334
|
-
try {
|
|
335
|
-
const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(10_000) });
|
|
336
|
-
if (!response.ok) return warn(`the endpoint answered /health with ${response.status}`);
|
|
337
|
-
|
|
338
|
-
// Asked anonymously, so it reports that the revision is up and nothing
|
|
339
|
-
// about what it serves — the profile list is behind the token now.
|
|
340
|
-
// `lanes link outputs` holds one and prints the rest.
|
|
341
|
-
const body = (await response.json()) as { profiles?: string[] };
|
|
342
|
-
return ok(
|
|
343
|
-
body.profiles
|
|
344
|
-
? `healthy — serving ${body.profiles.join(', ')}`
|
|
345
|
-
: 'healthy — run `lanes link outputs` with this profile and target for what it serves',
|
|
346
|
-
);
|
|
347
|
-
} catch {
|
|
348
|
-
// A cold start plus a database connect can outrun a short probe, and
|
|
349
|
-
// `access: iam` makes /health unreachable from here by design. Neither is a
|
|
350
|
-
// failed deploy.
|
|
351
|
-
return warn('could not reach /health from here — with access: iam that is expected');
|
|
298
|
+
/** `--target` is required by `SELECTION`; this is the type narrowing, not a check. */
|
|
299
|
+
function requireTargetFlag(flags: DeployFlags): string {
|
|
300
|
+
if (!flags.target) {
|
|
301
|
+
throw new ConfigError('--target is required. It names the deployment this acts on.');
|
|
352
302
|
}
|
|
303
|
+
return flags.target;
|
|
353
304
|
}
|