@lanes-sh/link 0.5.4 → 0.6.1
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/instructions/agents/lanes-link-scout.md +1 -1
- package/instructions/skills/lanes-link/SKILL.md +33 -29
- package/package.json +2 -1
- package/src/cli/commands/connect/target-note.ts +2 -2
- package/src/cli/commands/knowledge/index.ts +14 -34
- package/src/cli/commands/mcp/register.ts +1 -1
- package/src/cli/commands/operate/dashboard.ts +2 -2
- package/src/cli/commands/operate/inspect.ts +5 -1
- package/src/cli/commands/operate/migrate.ts +85 -1
- package/src/cli/commands/operate/outputs.ts +7 -22
- package/src/cli/commands/operate/status.ts +84 -69
- package/src/cli/commands/operate/tools.ts +1 -1
- package/src/cli/commands/profile/removal.ts +36 -25
- package/src/cli/commands/profile/remove.ts +5 -2
- package/src/cli/commands/profile.ts +56 -40
- package/src/cli/commands/sync.ts +94 -162
- package/src/cli/commands/target.ts +115 -74
- package/src/cli/commands/update.ts +56 -1
- package/src/cli/config-edit.ts +47 -16
- package/src/cli/endpoint-url.ts +3 -3
- package/src/cli/main.ts +9 -7
- package/src/cli/migrate-plan.ts +160 -0
- package/src/cli/publish.ts +4 -2
- package/src/cli/runtime/open.ts +19 -10
- package/src/cli/runtime/select.ts +69 -22
- package/src/cli/selection-require.ts +79 -0
- package/src/cli/selection.ts +44 -92
- package/src/cli/workspace-migrate.ts +263 -0
- package/src/deployments/bootstrap.ts +31 -11
- package/src/deployments/deploy.ts +103 -27
- package/src/deployments/knowledge.ts +5 -2
- package/src/deployments/prepare.ts +3 -1
- package/src/deployments/serving.ts +19 -15
- package/src/deployments/upload.ts +10 -1
- package/src/profile/deployments.ts +64 -53
- package/src/profile/index.ts +22 -6
- package/src/profile/legacy.ts +92 -0
- package/src/profile/load.ts +12 -28
- package/src/profile/registry.ts +182 -0
- package/src/profile/schema.ts +162 -110
- package/src/profile/targets.ts +62 -91
- package/src/profile/testing.ts +78 -0
- package/src/profile/workspace.ts +11 -25
- package/src/server/dashboard.ts +4 -1
- package/src/server/harness.ts +1 -6
- package/src/cli/commands/profile/declare.ts +0 -154
- package/src/deployments/servable.ts +0 -82
- package/src/deployments/sync-apply.ts +0 -330
- package/src/deployments/sync.ts +0 -164
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describeKnowledge } from '#deployments/knowledge.ts';
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
isPointer,
|
|
4
|
+
listProfiles,
|
|
5
|
+
notInRegistry,
|
|
6
|
+
openTarget,
|
|
7
|
+
readRegistry,
|
|
8
|
+
resolveWorkspaceRoot,
|
|
9
|
+
type WorkspaceTarget,
|
|
8
10
|
} from '#profile';
|
|
9
11
|
import { ConfigError } from '#profile';
|
|
10
12
|
import { deployedUrl, deploymentIdentity, type DeploymentIdentity } from '../endpoint-url.ts';
|
|
@@ -13,18 +15,23 @@ import { announce, emit, heading, ok, print, style, table, waiting, warn } from
|
|
|
13
15
|
import type { GlobalFlags } from '../runtime.ts';
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
|
-
* `lanes link target` — the
|
|
18
|
+
* `lanes link target` — the targets this workspace knows, and where each lives.
|
|
17
19
|
*
|
|
18
|
-
* A target names *
|
|
19
|
-
* and
|
|
20
|
-
*
|
|
21
|
-
*
|
|
20
|
+
* A target names *a workspace*: a credential store, a blob store, optionally a
|
|
21
|
+
* deployment, and the profiles that live in it. Under ADR-052 it is declared
|
|
22
|
+
* once, by the workspace that is it — not once per profile, which is what let
|
|
23
|
+
* two profiles disagree about whether a running deployment existed.
|
|
22
24
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* So this command no longer takes `--profile`. It never really wanted one: the
|
|
26
|
+
* question is "what can I pass to --target", and that had the same answer for
|
|
27
|
+
* every profile in all but the broken cases.
|
|
28
|
+
*
|
|
29
|
+
* **`list` does not follow pointers.** A registry entry either declares its
|
|
30
|
+
* adapters here or names the workspace that does, and following the second kind
|
|
31
|
+
* is a network read per entry. `list` prints what the registry says, so it stays
|
|
32
|
+
* instant and works offline; `show` follows one target and reports what is
|
|
33
|
+
* really there. That split is why a listing can be trusted when the bucket is
|
|
34
|
+
* unreachable — which is exactly when someone is running it.
|
|
28
35
|
*
|
|
29
36
|
* Each command is a data function plus a printing wrapper, the split
|
|
30
37
|
* `profile.ts` uses and for the same reason: `--json` wants the facts without
|
|
@@ -35,9 +42,18 @@ export interface TargetSummary {
|
|
|
35
42
|
readonly name: string;
|
|
36
43
|
/** Whether this is the one `--target` named, when it named any. */
|
|
37
44
|
readonly isSelected: boolean;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Where the declaration lives: `null` for one this workspace makes itself,
|
|
47
|
+
* otherwise the workspace it points at.
|
|
48
|
+
*
|
|
49
|
+
* The whole listing hangs off this. A pointer's adapters are not read by
|
|
50
|
+
* `list`, so every field below it is null for one — which is honest rather
|
|
51
|
+
* than lossy: they are somewhere else, and `show` is the command that goes.
|
|
52
|
+
*/
|
|
53
|
+
readonly pointsAt: string | null;
|
|
54
|
+
readonly credentials: string | null;
|
|
55
|
+
readonly storage: string | null;
|
|
56
|
+
readonly vault: string | null;
|
|
41
57
|
/**
|
|
42
58
|
* Where memory and skills are kept, when that is not `storage` above.
|
|
43
59
|
*
|
|
@@ -47,7 +63,7 @@ export interface TargetSummary {
|
|
|
47
63
|
* the log and the state and false of the owner's own notes.
|
|
48
64
|
*/
|
|
49
65
|
readonly knowledge: string | null;
|
|
50
|
-
/** Whether a deployment is declared.
|
|
66
|
+
/** Whether a deployment is declared. Unknown, and false, for a pointer. */
|
|
51
67
|
readonly deployed: boolean;
|
|
52
68
|
readonly deployment: DeploymentIdentity | null;
|
|
53
69
|
/** Only when asked for; absent is "not asked", null is "asked, no answer". */
|
|
@@ -56,8 +72,6 @@ export interface TargetSummary {
|
|
|
56
72
|
|
|
57
73
|
export interface TargetListing {
|
|
58
74
|
readonly root: string;
|
|
59
|
-
readonly profile: string;
|
|
60
|
-
readonly path: string;
|
|
61
75
|
/** What `--target` named, when it named anything. */
|
|
62
76
|
readonly selected: string | null;
|
|
63
77
|
/**
|
|
@@ -76,64 +90,79 @@ export interface TargetFlags extends GlobalFlags {
|
|
|
76
90
|
}
|
|
77
91
|
|
|
78
92
|
/**
|
|
79
|
-
* The
|
|
93
|
+
* The registry, and what each entry says about itself — in one pass.
|
|
80
94
|
*
|
|
81
95
|
* **Deliberately not `resolveProfile`.** That helper resolves the target the way
|
|
82
96
|
* every other command needs it resolved: by refusing a name that is not
|
|
83
|
-
* declared. Here that is exactly backwards.
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
97
|
+
* declared. Here that is exactly backwards. `--target clod` is the state in
|
|
98
|
+
* which every other command has just started failing, and this is the command
|
|
99
|
+
* someone runs to find out why — so it has to survive the condition it exists to
|
|
100
|
+
* diagnose. It reports whether the name landed on anything rather than throwing.
|
|
101
|
+
* `readProfiles` in `profile.ts` declines to parse configs for the same reason.
|
|
102
|
+
*
|
|
103
|
+
* It reads no profile at all now. The registry is the answer, and it is one file
|
|
104
|
+
* (ADR-052).
|
|
89
105
|
*/
|
|
90
106
|
async function survey(
|
|
91
107
|
flags: TargetFlags,
|
|
92
108
|
options: { urls?: boolean; env?: Record<string, string | undefined> } = {},
|
|
93
|
-
): Promise<{
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
...(options.env !== undefined ? { env: options.env } : {}),
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
const { config } = await loadProfileConfig(selection.workspaceRoot, selection.profile);
|
|
109
|
+
): Promise<{ root: string; listing: TargetListing }> {
|
|
110
|
+
const root = resolveWorkspaceRoot(options.env !== undefined ? { env: options.env } : {});
|
|
111
|
+
const registry = await readRegistry(root);
|
|
100
112
|
|
|
101
113
|
const selected = flags.target ?? null;
|
|
102
|
-
const names = Object.keys(
|
|
114
|
+
const names = Object.keys(registry).sort();
|
|
103
115
|
|
|
104
116
|
const summaries: TargetSummary[] = names.map((name) => {
|
|
105
|
-
const
|
|
106
|
-
return
|
|
107
|
-
name,
|
|
108
|
-
isSelected: name === selected,
|
|
109
|
-
credentials: declared.credentials.adapter,
|
|
110
|
-
storage: declared.storage.adapter,
|
|
111
|
-
vault: declared.vault?.adapter ?? 'file',
|
|
112
|
-
knowledge: declared.knowledge ? describeKnowledge(declared.knowledge) : null,
|
|
113
|
-
deployed: declared.deploy !== undefined,
|
|
114
|
-
deployment: deploymentIdentity(declared.deploy),
|
|
115
|
-
};
|
|
117
|
+
const entry = registry[name]!;
|
|
118
|
+
return summarise(name, entry, name === selected);
|
|
116
119
|
});
|
|
117
120
|
|
|
118
121
|
return {
|
|
119
|
-
|
|
120
|
-
config,
|
|
122
|
+
root,
|
|
121
123
|
listing: {
|
|
122
|
-
root
|
|
123
|
-
profile: selection.profile,
|
|
124
|
-
path: selection.profilePath,
|
|
124
|
+
root,
|
|
125
125
|
selected,
|
|
126
126
|
selectedDeclared: selected === null || names.includes(selected),
|
|
127
127
|
// Asking the platform is opt-in: one `gcloud` subprocess per deployable
|
|
128
|
-
// target, and the
|
|
128
|
+
// target, and the workspaces that make this command worth running are
|
|
129
129
|
// exactly the ones with several. A discovery command that takes ten
|
|
130
130
|
// seconds and needs a cloud CLI installed is one nobody runs twice —
|
|
131
131
|
// `outputs --target X` is already the command that asks.
|
|
132
|
-
targets: options.urls === true ? await withUrls(
|
|
132
|
+
targets: options.urls === true ? await withUrls(registry, summaries) : summaries,
|
|
133
133
|
},
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/** One entry, rendered without following it. */
|
|
138
|
+
function summarise(name: string, entry: WorkspaceTarget, isSelected: boolean): TargetSummary {
|
|
139
|
+
if (isPointer(entry)) {
|
|
140
|
+
return {
|
|
141
|
+
name,
|
|
142
|
+
isSelected,
|
|
143
|
+
pointsAt: entry.workspace,
|
|
144
|
+
credentials: null,
|
|
145
|
+
storage: null,
|
|
146
|
+
vault: null,
|
|
147
|
+
knowledge: null,
|
|
148
|
+
deployed: false,
|
|
149
|
+
deployment: null,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
name,
|
|
155
|
+
isSelected,
|
|
156
|
+
pointsAt: null,
|
|
157
|
+
credentials: entry.credentials?.adapter ?? null,
|
|
158
|
+
storage: entry.storage?.adapter ?? null,
|
|
159
|
+
vault: entry.vault?.adapter ?? 'file',
|
|
160
|
+
knowledge: null,
|
|
161
|
+
deployed: entry.deploy !== undefined,
|
|
162
|
+
deployment: deploymentIdentity(entry.deploy),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
137
166
|
/** What `--json` and the tests want, without the rendering. */
|
|
138
167
|
export async function readTargets(
|
|
139
168
|
flags: TargetFlags,
|
|
@@ -144,7 +173,7 @@ export async function readTargets(
|
|
|
144
173
|
|
|
145
174
|
/** Every deployable target's address, asked for at once rather than in turn. */
|
|
146
175
|
async function withUrls(
|
|
147
|
-
|
|
176
|
+
registry: Record<string, WorkspaceTarget>,
|
|
148
177
|
summaries: readonly TargetSummary[],
|
|
149
178
|
): Promise<TargetSummary[]> {
|
|
150
179
|
return waiting('asking the platform for addresses', () =>
|
|
@@ -154,7 +183,7 @@ async function withUrls(
|
|
|
154
183
|
// Resolves to null immediately for a target with no deployment, and
|
|
155
184
|
// swallows a missing or unauthenticated `gcloud` — neither is a reason
|
|
156
185
|
// for a listing to fail.
|
|
157
|
-
url: await deployedUrl(
|
|
186
|
+
url: await deployedUrl(registry[summary.name]?.deploy),
|
|
158
187
|
})),
|
|
159
188
|
),
|
|
160
189
|
);
|
|
@@ -164,16 +193,23 @@ export async function targetList(flags: TargetFlags): Promise<void> {
|
|
|
164
193
|
const { listing } = await survey(flags, { urls: flags.urls === true });
|
|
165
194
|
|
|
166
195
|
return emit(flags.json, listing, () => {
|
|
167
|
-
print(style.dim(
|
|
196
|
+
print(style.dim(listing.root));
|
|
168
197
|
print();
|
|
169
198
|
|
|
170
199
|
table(
|
|
171
200
|
listing.targets.map((target) => [
|
|
172
201
|
` ${target.isSelected ? style.cyan('→') : ' '}`,
|
|
173
202
|
style.bold(target.name),
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
203
|
+
// A pointer says where it lives instead of what it is made of. Its
|
|
204
|
+
// adapters are declared in that workspace and reading them is a network
|
|
205
|
+
// call `list` deliberately does not make.
|
|
206
|
+
...(target.pointsAt !== null
|
|
207
|
+
? [style.dim(target.pointsAt), '', '']
|
|
208
|
+
: [
|
|
209
|
+
style.dim(target.credentials ?? ''),
|
|
210
|
+
style.dim(target.storage ?? ''),
|
|
211
|
+
deploymentCell(target),
|
|
212
|
+
]),
|
|
177
213
|
]),
|
|
178
214
|
);
|
|
179
215
|
|
|
@@ -233,13 +269,15 @@ export function targetUse(name: string | undefined): never {
|
|
|
233
269
|
/**
|
|
234
270
|
* `lanes link target show [name]` — one target's adapters, and where it answers.
|
|
235
271
|
*
|
|
236
|
-
* The deep counterpart to `list`, and the one that *does*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
272
|
+
* The deep counterpart to `list`, and the one that *does* go and look: it
|
|
273
|
+
* follows a pointer to the workspace that declares the target, then asks the
|
|
274
|
+
* platform for the address. One target, one hop, one subprocess, and you named
|
|
275
|
+
* it. Nothing else prints a target's adapter set — `config show` dumps a
|
|
276
|
+
* profile, which no longer carries one — which is what earns this its place
|
|
277
|
+
* beside `outputs`.
|
|
240
278
|
*/
|
|
241
279
|
export async function targetShow(name: string | undefined, flags: TargetFlags): Promise<void> {
|
|
242
|
-
const {
|
|
280
|
+
const { root, listing } = await survey(flags);
|
|
243
281
|
|
|
244
282
|
// Positionally or by flag, but one of them: this command's whole subject is a
|
|
245
283
|
// single target, and there is no default left to mean "the one you would have
|
|
@@ -247,28 +285,31 @@ export async function targetShow(name: string | undefined, flags: TargetFlags):
|
|
|
247
285
|
const wanted = name ?? listing.selected;
|
|
248
286
|
if (!wanted) {
|
|
249
287
|
throw new ConfigError(
|
|
250
|
-
'Usage: lanes link target show <name
|
|
288
|
+
'Usage: lanes link target show <name>\n' +
|
|
251
289
|
` Declared here: ${listing.targets.map((one) => one.name).join(', ') || 'none'}`,
|
|
252
290
|
);
|
|
253
291
|
}
|
|
254
292
|
|
|
255
|
-
const
|
|
256
|
-
|
|
293
|
+
const resolved = await openTarget(root, wanted);
|
|
294
|
+
const summary = summarise(wanted, { ...resolved.declared, ...resolved.entry }, true);
|
|
295
|
+
const profiles = await listProfiles(resolved.workspaceRoot);
|
|
257
296
|
|
|
258
297
|
const url = summary.deployment
|
|
259
298
|
? await waiting('asking the platform for an address', () =>
|
|
260
|
-
deployedUrl(
|
|
299
|
+
deployedUrl(resolved.declared.deploy),
|
|
261
300
|
)
|
|
262
301
|
: null;
|
|
263
302
|
|
|
264
|
-
return emit(flags.json, { ...summary, url }, () => {
|
|
265
|
-
print(style.dim(
|
|
303
|
+
return emit(flags.json, { ...summary, workspace: resolved.workspaceRoot, profiles, url }, () => {
|
|
304
|
+
print(style.dim(resolved.workspaceRoot));
|
|
266
305
|
|
|
267
306
|
heading(summary.name);
|
|
268
307
|
table([
|
|
269
|
-
['
|
|
270
|
-
['
|
|
271
|
-
['
|
|
308
|
+
[' workspace', resolved.workspaceRoot],
|
|
309
|
+
[' profiles', profiles.join(', ') || style.dim('none yet')],
|
|
310
|
+
[' credentials', summary.credentials ?? ''],
|
|
311
|
+
[' storage', summary.storage ?? ''],
|
|
312
|
+
[' vault', summary.vault ?? ''],
|
|
272
313
|
...(summary.knowledge
|
|
273
314
|
? [[' knowledge', summary.knowledge, style.dim('memory and skills')]]
|
|
274
315
|
: []),
|
|
@@ -2,6 +2,7 @@ import { homedir } from 'node:os';
|
|
|
2
2
|
import { join, sep } from 'node:path';
|
|
3
3
|
import { installRoot, resolveWorkspaceRoot } from '#profile';
|
|
4
4
|
import { repairOwnerLayer } from '../config-repair.ts';
|
|
5
|
+
import { migrateWorkspace, needsMigration } from '../workspace-migrate.ts';
|
|
5
6
|
import { emit, fail, ok, print, printErr, progress, style, warn } from '../output.ts';
|
|
6
7
|
import { PACKAGE, release, type ReleaseState } from '../release.ts';
|
|
7
8
|
import { version } from '../version.ts';
|
|
@@ -169,7 +170,21 @@ export async function update(flags: UpdateFlags): Promise<void> {
|
|
|
169
170
|
// profile of someone already on the latest version is exactly the one this was
|
|
170
171
|
// reported against.
|
|
171
172
|
if (flags.check !== true) {
|
|
172
|
-
|
|
173
|
+
const root = resolveWorkspaceRoot();
|
|
174
|
+
|
|
175
|
+
// The contract migration before the owner-layer repair, and the order is not
|
|
176
|
+
// cosmetic: the repair opens profiles through the ordinary loader, and the
|
|
177
|
+
// loader refuses contract 1 outright (ADR-052). On a workspace that has not
|
|
178
|
+
// been migrated the repair has nothing it can read.
|
|
179
|
+
//
|
|
180
|
+
// Local workspace only. A remote one is a bucket whose endpoint is running a
|
|
181
|
+
// pinned image, and migrating it from here would leave that revision reading
|
|
182
|
+
// a contract it does not implement until someone redeploys. `deploy` is what
|
|
183
|
+
// migrates a bucket, because it is the command that ships the image in the
|
|
184
|
+
// same breath.
|
|
185
|
+
await migrateLocal(root, flags.json === true ? progress : print);
|
|
186
|
+
|
|
187
|
+
await repairOwnerLayer(root, undefined, {
|
|
173
188
|
...(flags.json === true ? { report: progress } : {}),
|
|
174
189
|
});
|
|
175
190
|
}
|
|
@@ -275,3 +290,43 @@ async function runInstall(argv: readonly string[], json: boolean): Promise<boole
|
|
|
275
290
|
return false;
|
|
276
291
|
}
|
|
277
292
|
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Bring the local workspace to the current contract, saying so if it did.
|
|
296
|
+
*
|
|
297
|
+
* Narrated rather than silent: this rewrites every profile in the workspace, and
|
|
298
|
+
* a command that reshapes somebody's config without a word is one they cannot
|
|
299
|
+
* audit afterwards. Routed to `report` for `--json`, where a line of prose in
|
|
300
|
+
* front of the document corrupts whatever is parsing it — the same routing
|
|
301
|
+
* `repairOwnerLayer` takes, for the same reason.
|
|
302
|
+
*
|
|
303
|
+
* A failure here is reported and swallowed. `update`'s job is to install a
|
|
304
|
+
* version, and a workspace that cannot migrate — a custom path that will not
|
|
305
|
+
* hoist, two profiles disagreeing about one target — is a thing to be told
|
|
306
|
+
* about, in a sentence naming the fix, rather than a reason for the upgrade to
|
|
307
|
+
* fail. `check` and `doctor` both refuse loudly on the next run.
|
|
308
|
+
*/
|
|
309
|
+
async function migrateLocal(root: string, say: (line: string) => void): Promise<void> {
|
|
310
|
+
try {
|
|
311
|
+
if (!(await needsMigration(root))) return;
|
|
312
|
+
|
|
313
|
+
const migration = await migrateWorkspace(root);
|
|
314
|
+
if (migration.alreadyCurrent) return;
|
|
315
|
+
|
|
316
|
+
say(
|
|
317
|
+
`migrated ${migration.profiles.length} profile(s) to contract 2 — a target is declared by ` +
|
|
318
|
+
'the workspace now, not by each profile',
|
|
319
|
+
);
|
|
320
|
+
for (const change of migration.changes) say(` ${change}`);
|
|
321
|
+
|
|
322
|
+
const pointers = migration.targets.filter((one) => one.kind === 'pointer');
|
|
323
|
+
for (const pointer of pointers) {
|
|
324
|
+
say(
|
|
325
|
+
` "${pointer.name}" points at ${pointer.where} — run ` +
|
|
326
|
+
`lanes link deploy --target ${pointer.name} to migrate what is there`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
} catch (error) {
|
|
330
|
+
say(`could not migrate this workspace: ${error instanceof Error ? error.message : String(error)}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
package/src/cli/config-edit.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
isRemoteWorkspace,
|
|
6
6
|
readWorkspaceFile,
|
|
7
7
|
validateConfig,
|
|
8
|
+
validateConfigShape,
|
|
8
9
|
workspaceFiles,
|
|
9
10
|
writeWorkspaceFile,
|
|
10
11
|
} from '#profile';
|
|
@@ -179,12 +180,28 @@ export class ConfigDocument {
|
|
|
179
180
|
* Validation happens on the rendered text rather than the in-memory tree so
|
|
180
181
|
* that what is checked is exactly what would land on disk.
|
|
181
182
|
*/
|
|
182
|
-
|
|
183
|
+
/**
|
|
184
|
+
* `shapeOnly` validates the schema and the secret scan, and skips the
|
|
185
|
+
* referential checks.
|
|
186
|
+
*
|
|
187
|
+
* For the contract 1 → 2 migration, and nothing else. That migration rewrites
|
|
188
|
+
* the structure of a file which may *also* carry an unrelated problem the
|
|
189
|
+
* loader refuses — a connection row still spelling a renamed provider is the
|
|
190
|
+
* one that actually happens. Blocking the structural fix on the unrelated one
|
|
191
|
+
* would leave the file stuck at a contract nothing reads, which is a worse
|
|
192
|
+
* place to be than contract 2 with a stale row that `doctor --fix` names and
|
|
193
|
+
* repairs.
|
|
194
|
+
*
|
|
195
|
+
* The half it keeps is the half that matters for a write: the schema, and the
|
|
196
|
+
* scan that stops a credential value being written into config.
|
|
197
|
+
*/
|
|
198
|
+
async save(options: { shapeOnly?: boolean } = {}): Promise<void> {
|
|
183
199
|
const rendered = this.toString();
|
|
184
200
|
|
|
185
201
|
// Throws on any validation failure, including a credential value that has
|
|
186
202
|
// crept in — so a CLI edit can never introduce one.
|
|
187
|
-
|
|
203
|
+
if (options.shapeOnly === true) validateConfigShape(this.#document.toJSON(), this.#path);
|
|
204
|
+
else validateConfig(this.#document.toJSON(), this.#path);
|
|
188
205
|
|
|
189
206
|
if (!this.#location) {
|
|
190
207
|
throw new ConfigError(`${this.#path}: opened from text, so there is nowhere to save it`);
|
|
@@ -217,7 +234,7 @@ export class ConfigDocument {
|
|
|
217
234
|
* Written with comments, because this is the file an operator will read first
|
|
218
235
|
* and most of what it needs to say is *why*, not *what*.
|
|
219
236
|
*/
|
|
220
|
-
export function newProfileTemplate(profile: string, port: number
|
|
237
|
+
export function newProfileTemplate(profile: string, port: number): string {
|
|
221
238
|
return `# Lanes Link profile: ${profile}
|
|
222
239
|
#
|
|
223
240
|
# This file is the source of truth for what exists. It never contains a
|
|
@@ -226,23 +243,24 @@ export function newProfileTemplate(profile: string, port: number, targets: strin
|
|
|
226
243
|
#
|
|
227
244
|
# Edit it by hand or through the CLI; both are supported, and CLI edits
|
|
228
245
|
# preserve your comments and ordering.
|
|
229
|
-
contract:
|
|
246
|
+
contract: 2
|
|
230
247
|
|
|
231
248
|
instance:
|
|
232
249
|
profile: ${profile}
|
|
233
250
|
port: ${port}
|
|
234
251
|
host: 127.0.0.1
|
|
235
252
|
|
|
236
|
-
#
|
|
253
|
+
# This file says nothing about where it runs, and that is the point.
|
|
254
|
+
#
|
|
255
|
+
# A profile lives in exactly one target, and the target is the workspace holding
|
|
256
|
+
# this file — which declares its own adapters, once, in lanes-link.yaml beside
|
|
257
|
+
# the profiles/ directory (ADR-052). Moving this profile somewhere else is
|
|
258
|
+
# copying the file there; there is no block in it to edit.
|
|
259
|
+
#
|
|
260
|
+
# Every command still names both, because neither is inferred (ADR-037):
|
|
237
261
|
#
|
|
238
262
|
# lanes link status --profile ${profile} --target <name>
|
|
239
263
|
#
|
|
240
|
-
# There is no default. A target is chosen on the command line or not at all,
|
|
241
|
-
# so a flag that goes missing fails here rather than quietly running somewhere
|
|
242
|
-
# else (ADR-037). Everything below "targets" is target-independent and declared
|
|
243
|
-
# exactly once.
|
|
244
|
-
targets:
|
|
245
|
-
${targets}
|
|
246
264
|
# The bearer token for the endpoint this profile serves.
|
|
247
265
|
#
|
|
248
266
|
# "lanes link start" serves every profile in the workspace from one URL, and this
|
|
@@ -323,10 +341,23 @@ export function newWorkspaceTemplate(): string {
|
|
|
323
341
|
# every call names the profile it means, with --profile. Profiles never share a
|
|
324
342
|
# database or a credential store, so what one holds is invisible to another.
|
|
325
343
|
#
|
|
326
|
-
#
|
|
327
|
-
#
|
|
328
|
-
#
|
|
329
|
-
#
|
|
330
|
-
|
|
344
|
+
# This workspace IS a target. "targets:" below says where its bytes go, once,
|
|
345
|
+
# for every profile in it — a profile says nothing about where it runs, so
|
|
346
|
+
# there is one copy of it and nothing to keep in step (ADR-052).
|
|
347
|
+
#
|
|
348
|
+
# A target somewhere else is a pointer, and "deploy" writes one:
|
|
349
|
+
#
|
|
350
|
+
# targets:
|
|
351
|
+
# cloud:
|
|
352
|
+
# workspace: gs://your-bucket
|
|
353
|
+
#
|
|
354
|
+
# The workspace at that address declares its own adapters, and is the only
|
|
355
|
+
# thing that does. Reading it is a network call, which is why "--target cloud"
|
|
356
|
+
# needs that bucket reachable.
|
|
357
|
+
contract: 2
|
|
358
|
+
targets:
|
|
359
|
+
local:
|
|
360
|
+
credentials: { adapter: file }
|
|
361
|
+
storage: { adapter: filesystem }
|
|
331
362
|
`;
|
|
332
363
|
}
|
package/src/cli/endpoint-url.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Config, DeployConfig } from '#profile';
|
|
1
|
+
import type { Config, DeployConfig, TargetConfig } from '#profile';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Where this profile's endpoint answers, for the target in play.
|
|
@@ -15,8 +15,8 @@ import type { Config, DeployConfig } from '#profile';
|
|
|
15
15
|
* answer and the other could not see it, which is what a copied line does
|
|
16
16
|
* eventually.
|
|
17
17
|
*/
|
|
18
|
-
export async function endpointUrl(config: Config,
|
|
19
|
-
const deployed = await deployedUrl(
|
|
18
|
+
export async function endpointUrl(config: Config, declared: TargetConfig): Promise<string> {
|
|
19
|
+
const deployed = await deployedUrl(declared.deploy);
|
|
20
20
|
return deployed ?? localUrl(config);
|
|
21
21
|
}
|
|
22
22
|
|
package/src/cli/main.ts
CHANGED
|
@@ -32,7 +32,8 @@ import { knowledgeShow, knowledgeUse } from './commands/knowledge.ts';
|
|
|
32
32
|
import { dispatchOwner } from './dispatch-owner.ts';
|
|
33
33
|
import { update } from './commands/update.ts';
|
|
34
34
|
import { all, customFlags, globalFlags, knowledgeFlags, ownerFlags, parseArgv, text } from './argv.ts';
|
|
35
|
-
import { assertKnownFlags
|
|
35
|
+
import { assertKnownFlags } from './selection.ts';
|
|
36
|
+
import { requireSelection } from './selection-require.ts';
|
|
36
37
|
import { PROGRAM, USAGE } from './usage.ts';
|
|
37
38
|
import { version } from './version.ts';
|
|
38
39
|
import { print } from './output.ts';
|
|
@@ -125,19 +126,20 @@ export async function run(argv: readonly string[]): Promise<void> {
|
|
|
125
126
|
switch (second) {
|
|
126
127
|
case 'add':
|
|
127
128
|
if (!rest[0]) {
|
|
128
|
-
throw new Error(`Usage: ${PROGRAM} profile add <name> --target <name
|
|
129
|
+
throw new Error(`Usage: ${PROGRAM} profile add <name> --target <name>`);
|
|
129
130
|
}
|
|
130
131
|
return profileAdd(rest[0], {
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
132
|
+
// One target, not a list. A profile declared every target it could
|
|
133
|
+
// run on under contract 1, which is why this read the repeated flag
|
|
134
|
+
// out of argv; it lives in exactly one now (ADR-052), so a second
|
|
135
|
+
// --target would be naming a second place to put the same file.
|
|
136
|
+
targets: [text(flags, 'target')!],
|
|
135
137
|
nonInteractive: flags['non-interactive'] === true,
|
|
136
138
|
json,
|
|
137
139
|
});
|
|
138
140
|
case 'list':
|
|
139
141
|
case undefined:
|
|
140
|
-
return profileList({ json });
|
|
142
|
+
return profileList(text(flags, 'target')!, { json });
|
|
141
143
|
case 'default':
|
|
142
144
|
if (!rest[0]) throw new Error(`Usage: ${PROGRAM} profile default <name>`);
|
|
143
145
|
return profileDefault(rest[0]);
|