@lanes-sh/link 0.3.0 → 0.3.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.
@@ -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
+ }
@@ -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: EXTERNAL (even with a Workspace domain: "Internal" admits only that one domain, so a mix of personal and Workspace accounts needs External). Add every account you will connect under "Test users".',
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 — 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.`,
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
- '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.',
65
- '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: publishing above removes the weekly re-authorisation today.',
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: [
@@ -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-040.
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] };