@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/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
- switch (second) {
255
- case 'list':
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
+ }
@@ -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
- if (!declared) throw new ConfigError(`Target "${target}" is not declared`);
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 };
@@ -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
- /** What a command must be told before it can act. */
34
- export type Requires = 'none' | 'profile' | 'profile+target';
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
- * `profile add` **rejects** both. The name is positional, and there is no
58
- * profile to select before it exists.
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': 'profile',
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
- status: 'profile+target',
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: 'profile+target',
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 remove': ['dry-run', 'yes'],
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
- ...(needs === 'profile' || needs === 'profile+target' ? ['profile'] : []),
307
- ...(needs === 'profile+target' ? ['target'] : []),
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] set up, build, and roll a revision
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
- const { resolution, config, target } = await resolveProfile(flags, {
61
- allowUndeclaredTarget: true,
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, flags.profile);
95
- const readable = await readableRefs(resolution.workspaceRoot, flags.profile, declared);
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
- profile: flags.profile,
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, flags.profile);
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, flags.profile);
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
- * How to register the endpoint, and when.
256
- *
257
- * The ordering is the whole point of the second half. A client captures
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
  }