@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/profile/schema.ts
CHANGED
|
@@ -366,7 +366,7 @@ export const configSchema = z.object({
|
|
|
366
366
|
*
|
|
367
367
|
* A deployment only, and this cannot widen that: a loopback endpoint
|
|
368
368
|
* refuses every cross-origin request and must keep doing so. Why the
|
|
369
|
-
* default is a wildcard is `src/server/cors.ts` and ADR-
|
|
369
|
+
* default is a wildcard is `src/server/cors.ts` and ADR-039.
|
|
370
370
|
*/
|
|
371
371
|
allowed_origins: z.array(browserOrigin).optional(),
|
|
372
372
|
})
|
|
@@ -406,10 +406,45 @@ export type AuthorizationConfig = z.infer<typeof authorizationSchema>;
|
|
|
406
406
|
export { authorizationSchema, identitySchema };
|
|
407
407
|
export type { IdentityEntry } from './identity.ts';
|
|
408
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Where a deployment lives — an index, not configuration.
|
|
411
|
+
*
|
|
412
|
+
* **Nothing resolves from this.** A target is still declared by the profile,
|
|
413
|
+
* and a command still acts on what the profile says. This exists because the
|
|
414
|
+
* profile file was the *only* record that a deployment existed, so rewriting
|
|
415
|
+
* one erased the sole pointer to a live service, a bucket holding every byte
|
|
416
|
+
* the endpoint remembered, and a credential store — none of which had gone
|
|
417
|
+
* anywhere. There was no command that could find them again.
|
|
418
|
+
*
|
|
419
|
+
* It lives in `lanes-link.yaml`, outside any profile, for exactly that reason:
|
|
420
|
+
* a record kept inside the thing it describes cannot survive the thing being
|
|
421
|
+
* lost. `lanes link sync targets` reads it, and reads it *only* to know where
|
|
422
|
+
* to look (ADR-044).
|
|
423
|
+
*
|
|
424
|
+
* Written by `deploy`, after an upload has succeeded — so it never names a
|
|
425
|
+
* workspace nothing was ever put in.
|
|
426
|
+
*/
|
|
427
|
+
export const deploymentRecordSchema = z.object({
|
|
428
|
+
target: identifier,
|
|
429
|
+
/** Where the deployed endpoint reads its config: `gs://bucket[/prefix]`. */
|
|
430
|
+
workspace: z.string().min(1),
|
|
431
|
+
/**
|
|
432
|
+
* Whose bearer token opens this endpoint (ADR-009).
|
|
433
|
+
*
|
|
434
|
+
* Recorded rather than inferred. One endpoint serves every profile in the
|
|
435
|
+
* bucket under one token, and which profile's token that is decides who gets
|
|
436
|
+
* in — the one question about a deployment that must not be guessed at.
|
|
437
|
+
*/
|
|
438
|
+
primary: identifier.optional(),
|
|
439
|
+
last_deploy: z.string().optional(),
|
|
440
|
+
});
|
|
441
|
+
|
|
409
442
|
/** The workspace file: `lanes-link.yaml`, alongside a `profiles/` directory. */
|
|
410
443
|
export const workspaceSchema = z.object({
|
|
411
444
|
contract: z.number().int().positive(),
|
|
412
445
|
default_profile: identifier.optional(),
|
|
446
|
+
deployments: z.array(deploymentRecordSchema).default([]),
|
|
413
447
|
});
|
|
414
448
|
|
|
415
449
|
export type WorkspaceConfig = z.infer<typeof workspaceSchema>;
|
|
450
|
+
export type DeploymentRecord = z.infer<typeof deploymentRecordSchema>;
|
package/src/profile/targets.ts
CHANGED
|
@@ -110,3 +110,56 @@ export function undeclaredTarget(target: string, config: Config, profile?: strin
|
|
|
110
110
|
|
|
111
111
|
return new ConfigError(`Target "${target}" is not declared by ${whose} (have: ${have})`);
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The refusal for a target-scoped command that named no target.
|
|
116
|
+
*
|
|
117
|
+
* The twin of `noTargetNamed`, for the commands whose subject is the target
|
|
118
|
+
* rather than one profile's view of it (ADR-043). It lists the target names the
|
|
119
|
+
* *workspace* declares and who declares each, because the question those
|
|
120
|
+
* commands are asking is "which endpoint", and a target only one profile knows
|
|
121
|
+
* about is the answer to a different question than one they all share.
|
|
122
|
+
*
|
|
123
|
+
* Naming the profiles is not decoration. A target declared by one profile and
|
|
124
|
+
* not its sibling is precisely the state that reads as a deployment having
|
|
125
|
+
* disappeared, and it is invisible from inside either profile alone.
|
|
126
|
+
*/
|
|
127
|
+
export function noTargetInWorkspace(
|
|
128
|
+
declared: ReadonlyMap<string, readonly string[]>,
|
|
129
|
+
workspaceRoot: string,
|
|
130
|
+
env: Record<string, string | undefined> = process.env as Record<string, string | undefined>,
|
|
131
|
+
): ConfigError {
|
|
132
|
+
if (declared.size === 0) {
|
|
133
|
+
return new ConfigError(
|
|
134
|
+
`--target is required, and no profile in ${workspaceRoot} declares one.\n` +
|
|
135
|
+
' Create a profile with: lanes link profile add <name> --target local',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const total = new Set(
|
|
140
|
+
[...declared.values()].flatMap((profiles) => profiles as readonly string[]),
|
|
141
|
+
).size;
|
|
142
|
+
|
|
143
|
+
const rows = [...declared.entries()]
|
|
144
|
+
.map(([name, profiles]) => {
|
|
145
|
+
// "every profile" rather than the list once it is all of them: the list is
|
|
146
|
+
// there to show a gap, and a complete one shows none.
|
|
147
|
+
const whose = profiles.length === total ? 'every profile' : profiles.join(', ');
|
|
148
|
+
return ` ${name} ${whose}`;
|
|
149
|
+
})
|
|
150
|
+
.join('\n');
|
|
151
|
+
|
|
152
|
+
const first = [...declared.keys()][0]!;
|
|
153
|
+
const stale = env[LEGACY_TARGET_ENV];
|
|
154
|
+
|
|
155
|
+
return new ConfigError(
|
|
156
|
+
'--target is required. This command acts on a target, and every profile\n' +
|
|
157
|
+
'that declares it.\n\n' +
|
|
158
|
+
` Targets in ${workspaceRoot}\n${rows}\n` +
|
|
159
|
+
`\n e.g. lanes link status --target ${first}` +
|
|
160
|
+
(stale
|
|
161
|
+
? `\n\n ${LEGACY_TARGET_ENV}=${stale} is set in this shell and is no longer read.\n` +
|
|
162
|
+
' Unset it, or pass --target.'
|
|
163
|
+
: ''),
|
|
164
|
+
);
|
|
165
|
+
}
|
package/src/profile/workspace.ts
CHANGED
|
@@ -247,3 +247,76 @@ export function workspacePath(workspaceRoot: string, path: string): string {
|
|
|
247
247
|
}
|
|
248
248
|
return isAbsolute(path) ? path : resolve(workspaceRoot, path);
|
|
249
249
|
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* One profile, loaded, for the commands whose subject is the whole workspace.
|
|
253
|
+
*
|
|
254
|
+
* `profilePath` rather than a bare name because a caller reporting on several
|
|
255
|
+
* profiles at once has nowhere to recompute it from without knowing whether the
|
|
256
|
+
* root is a bucket.
|
|
257
|
+
*/
|
|
258
|
+
export interface LoadedProfile {
|
|
259
|
+
readonly profile: string;
|
|
260
|
+
readonly profilePath: string;
|
|
261
|
+
readonly config: Config;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Every profile in the workspace, and the ones that would not open.
|
|
266
|
+
*
|
|
267
|
+
* **Skipping rather than failing** is the same rule `openReconciled` follows for
|
|
268
|
+
* a deployed endpoint: a workspace holding one broken profile still has a true
|
|
269
|
+
* answer to give about the others, and a listing that dies on the first bad file
|
|
270
|
+
* is one that stops working exactly when it is needed. What it must not do is
|
|
271
|
+
* skip *silently* — `unreadable` is the half a caller has to print, and the
|
|
272
|
+
* reason it carries the message rather than the error is that a caller rendering
|
|
273
|
+
* a table has no use for a stack.
|
|
274
|
+
*/
|
|
275
|
+
export interface WorkspaceProfiles {
|
|
276
|
+
readonly workspaceRoot: string;
|
|
277
|
+
readonly loaded: readonly LoadedProfile[];
|
|
278
|
+
readonly unreadable: readonly { readonly profile: string; readonly reason: string }[];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function loadWorkspaceProfiles(workspaceRoot: string): Promise<WorkspaceProfiles> {
|
|
282
|
+
const loaded: LoadedProfile[] = [];
|
|
283
|
+
const unreadable: { profile: string; reason: string }[] = [];
|
|
284
|
+
|
|
285
|
+
for (const profile of await listProfiles(workspaceRoot)) {
|
|
286
|
+
try {
|
|
287
|
+
const { config } = await loadProfileConfig(workspaceRoot, profile);
|
|
288
|
+
loaded.push({ profile, profilePath: profilePath(workspaceRoot, profile), config });
|
|
289
|
+
} catch (error) {
|
|
290
|
+
// First line only: a `ConfigError` from the loader carries every schema
|
|
291
|
+
// issue on its own line, and a row in a table has room for none of them.
|
|
292
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
293
|
+
unreadable.push({ profile, reason: message.split('\n')[0] ?? 'could not be read' });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return { workspaceRoot, loaded, unreadable };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Which profiles declare each target name, across the whole workspace.
|
|
302
|
+
*
|
|
303
|
+
* A target is declared per profile and the endpoint serves every profile in the
|
|
304
|
+
* workspace (ADR-009), so "who has `cloud`" is a question with a list for an
|
|
305
|
+
* answer rather than a yes or no. The disagreement — one profile declaring it
|
|
306
|
+
* and its sibling not — is the state that reads as a target having vanished.
|
|
307
|
+
*/
|
|
308
|
+
export function targetsByName(
|
|
309
|
+
workspace: WorkspaceProfiles,
|
|
310
|
+
): ReadonlyMap<string, readonly string[]> {
|
|
311
|
+
const byName = new Map<string, string[]>();
|
|
312
|
+
|
|
313
|
+
for (const { profile, config } of workspace.loaded) {
|
|
314
|
+
for (const target of Object.keys(config.targets)) {
|
|
315
|
+
const profiles = byName.get(target);
|
|
316
|
+
if (profiles) profiles.push(profile);
|
|
317
|
+
else byName.set(target, [profile]);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return byName;
|
|
322
|
+
}
|
|
@@ -40,6 +40,21 @@ export const googleSetup = (
|
|
|
40
40
|
summary: `${product} signs in with Google. By default it authorises against the OAuth client Lanes operates, so there is nothing to register and no client secret on this machine — the code is exchanged for a token by the Lanes API, which holds that secret. Pass --own-client to register a client of your own instead; the steps below are that path, asked once per profile and then covering every Google account you connect.`,
|
|
41
41
|
docs: 'docs/detailed/setup/google.md',
|
|
42
42
|
docs_url: 'https://console.cloud.google.com/auth',
|
|
43
|
+
// AUDIENCE leads with Internal, and the ordering is the point rather than a
|
|
44
|
+
// preference. The seven-day `invalid_grant` everyone hits is a property of
|
|
45
|
+
// publishing status, and an Internal app has no publishing status to have —
|
|
46
|
+
// so it also has no expiry, no verification question, no unverified-app
|
|
47
|
+
// warning, no test-user list, and no scope registration. Steering a
|
|
48
|
+
// Workspace user to External costs them every one of those, to buy reach
|
|
49
|
+
// they do not want: Internal admits only their own domain, which is the
|
|
50
|
+
// whole set of accounts they were going to connect.
|
|
51
|
+
//
|
|
52
|
+
// What it cannot be is the only path. "Internal" is Google's word for
|
|
53
|
+
// "inside my Workspace organisation", not "private to me", and the option
|
|
54
|
+
// is simply absent on a project with no organisation behind it. So a
|
|
55
|
+
// personal @gmail.com still needs External and still has to publish, and
|
|
56
|
+
// the steps say which half applies to whom rather than making everyone read
|
|
57
|
+
// both.
|
|
43
58
|
steps: [
|
|
44
59
|
...(options.preview
|
|
45
60
|
? [
|
|
@@ -56,13 +71,13 @@ export const googleSetup = (
|
|
|
56
71
|
}\n Without gcloud: APIs & Services → Library, and search for each by name.`,
|
|
57
72
|
'The rest is under Google Auth Platform — https://console.cloud.google.com/auth — in this order:',
|
|
58
73
|
' BRANDING — app name and a support email. Seen by nobody but you.',
|
|
59
|
-
' AUDIENCE — User type
|
|
60
|
-
' AUDIENCE, again — PUBLISH the app. This is the setting that decides whether your connections survive the week, and it is not the same thing as verification: a client left in "Testing" has every refresh token it issues expired after exactly seven days, and one set to "In production" does not, review pending or not.',
|
|
61
|
-
` DATA ACCESS —
|
|
74
|
+
' AUDIENCE — User type, and this is the step that decides how much of the rest you do.\n INTERNAL, if the project sits in a Google Workspace organisation and every account you will connect is on that domain. An Internal app has no publishing status at all — so no seven-day expiry, no verification, no test-user list, no unverified-app warning screen, and no scopes to register. Skip to CLIENTS.\n EXTERNAL, for a personal @gmail.com, or a mix of personal and Workspace accounts. Internal is not offered without a Workspace organisation, and admits only your own domain. Add every account you will connect under "Test users", then do the two steps below.',
|
|
75
|
+
' AUDIENCE, again — EXTERNAL ONLY: PUBLISH the app. This is the setting that decides whether your connections survive the week, and it is not the same thing as verification: a client left in "Testing" has every refresh token it issues expired after exactly seven days, and one set to "In production" does not, review pending or not. Internal apps have no publishing status, which is why they never expire.',
|
|
76
|
+
` DATA ACCESS — EXTERNAL ONLY. Where scopes live now. Add:\n ${scopes.join('\n ')}\n Note drive.file is filed under "sensitive" rather than "restricted", so it appears in a different section of that page. An Internal app does not need this: it authorises scopes that were never registered here, so the page can stay empty.`,
|
|
62
77
|
' CLIENTS — Create OAuth client → type: DESKTOP APP. Google\'s docs say "Web application" with a redirect URI for Claude or Antigravity, because they assume the agent host runs the OAuth. Here the CLI does, on a loopback port — which is also why this stays Desktop even when the server runs on Cloud Run.',
|
|
63
78
|
'Copy the client ID and secret — you are asked for them next.',
|
|
64
|
-
'
|
|
65
|
-
'
|
|
79
|
+
'EXTERNAL ONLY — what publishing unverified costs, so it is a decision rather than a surprise: everyone you connect sees a "Google hasn\'t verified this app" screen and has to click through Advanced, and the project gains a cap of 100 new users granted these scopes. That cap is for the lifetime of the project and cannot be reset — which does not matter for a client only you use, and matters a great deal for one you intend to hand out. None of this applies to an Internal app.',
|
|
80
|
+
'EXTERNAL ONLY — verification itself is the other path and a much longer one: restricted scopes mean a review with a security assessment measured in months. It is worth starting and not worth waiting on, since publishing above removes the weekly re-authorisation today. If you have a Workspace domain, Internal skips this question entirely.',
|
|
66
81
|
'If none of that suits — an organisation that forbids publishing, or a client that must stay in Testing — connect with a service account key instead. It does not expire at all: lanes link connect ' + product.toLowerCase().split(' ')[0] + ' --auth service_account',
|
|
67
82
|
],
|
|
68
83
|
prompts: [
|
package/src/server/index.ts
CHANGED
|
@@ -371,7 +371,7 @@ export function serve(options: ServeOptions): RunningServer {
|
|
|
371
371
|
// Cross-origin access, and its absence, are decided here for the same reason
|
|
372
372
|
// `allowedHostnames` and `dashboard` are: they are all properties of what this
|
|
373
373
|
// is bound to. The two are mutually exclusive and the exclusion is the
|
|
374
|
-
// decision — see `./cors.ts`, and ADR-
|
|
374
|
+
// decision — see `./cors.ts`, and ADR-039.
|
|
375
375
|
const cors: CorsPolicy | undefined = loopback
|
|
376
376
|
? undefined
|
|
377
377
|
: { allowedOrigins: primary.config.auth.allowed_origins ?? [ANY_ORIGIN] };
|