@brass-build/cli 0.3.0 → 0.4.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.
Files changed (60) hide show
  1. package/AGENTS.md +177 -11
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +76 -14
  4. package/dist/api.d.ts +45 -0
  5. package/dist/api.d.ts.map +1 -1
  6. package/dist/api.js +21 -2
  7. package/dist/api.js.map +1 -1
  8. package/dist/approval-prompt.d.ts +25 -0
  9. package/dist/approval-prompt.d.ts.map +1 -0
  10. package/dist/approval-prompt.js +44 -0
  11. package/dist/approval-prompt.js.map +1 -0
  12. package/dist/args.d.ts.map +1 -1
  13. package/dist/args.js +10 -1
  14. package/dist/args.js.map +1 -1
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +197 -19
  17. package/dist/cli.js.map +1 -1
  18. package/dist/commands.d.ts +28 -1
  19. package/dist/commands.d.ts.map +1 -1
  20. package/dist/commands.js +369 -29
  21. package/dist/commands.js.map +1 -1
  22. package/dist/config.d.ts +1 -0
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/config.js +8 -1
  25. package/dist/config.js.map +1 -1
  26. package/dist/login.d.ts +1 -0
  27. package/dist/login.d.ts.map +1 -1
  28. package/dist/login.js +24 -12
  29. package/dist/login.js.map +1 -1
  30. package/dist/project.d.ts +8 -0
  31. package/dist/project.d.ts.map +1 -1
  32. package/dist/project.js +75 -7
  33. package/dist/project.js.map +1 -1
  34. package/dist/sdk-pairing.d.ts +9 -0
  35. package/dist/sdk-pairing.d.ts.map +1 -0
  36. package/dist/sdk-pairing.js +42 -0
  37. package/dist/sdk-pairing.js.map +1 -0
  38. package/dist/session.d.ts +18 -1
  39. package/dist/session.d.ts.map +1 -1
  40. package/dist/session.js +95 -12
  41. package/dist/session.js.map +1 -1
  42. package/dist/store.d.ts +10 -0
  43. package/dist/store.d.ts.map +1 -1
  44. package/dist/store.js +41 -0
  45. package/dist/store.js.map +1 -1
  46. package/dist/version.d.ts +2 -2
  47. package/dist/version.js +1 -1
  48. package/package.json +2 -1
  49. package/src/api.ts +80 -2
  50. package/src/approval-prompt.ts +75 -0
  51. package/src/args.ts +10 -1
  52. package/src/cli.ts +240 -18
  53. package/src/commands.ts +479 -36
  54. package/src/config.ts +15 -2
  55. package/src/login.ts +34 -11
  56. package/src/project.ts +82 -7
  57. package/src/sdk-pairing.ts +53 -0
  58. package/src/session.ts +135 -11
  59. package/src/store.ts +77 -0
  60. package/src/version.ts +1 -1
package/src/cli.ts CHANGED
@@ -23,25 +23,44 @@ import {
23
23
  } from './config.js';
24
24
  import {
25
25
  readCredentialsFile,
26
+ readPendingApproval,
26
27
  readPendingLogin,
28
+ writePendingApproval,
27
29
  writePendingLogin,
28
30
  writeStoredCredential,
29
31
  } from './store.js';
30
32
  import { BrassApi, BrassApiError, type AppVisibility } from './api.js';
31
33
  import { serviceTokenAuth, type AuthProvider } from './auth.js';
32
- import { loginDevice, postDeviceCancel, postSignOut, sessionAuth } from './session.js';
34
+ import {
35
+ loginDevice,
36
+ postDeviceAppSession,
37
+ postDeviceCancel,
38
+ postSignOut,
39
+ sessionAuth,
40
+ } from './session.js';
33
41
  import { loginStart, loginCheck } from './login.js';
42
+ import { renderApprovalPrompt } from './approval-prompt.js';
34
43
  import { createLogger, type Logger } from './log.js';
35
44
  import {
36
45
  publish,
37
46
  schemaPull,
47
+ documentContents,
38
48
  agentsPull,
39
49
  whoami,
40
50
  status,
41
51
  type CommandContext,
42
52
  type CredentialKind,
43
53
  } from './commands.js';
44
- import { readProjectState, resolveAppId, readManifest } from './project.js';
54
+ import {
55
+ readProjectState,
56
+ resolveAppId,
57
+ loadFirstManifest,
58
+ firstExistingManifestPath,
59
+ publishManifestCandidates,
60
+ sourceManifestCandidates,
61
+ MANIFEST_FILENAME,
62
+ type AppManifest,
63
+ } from './project.js';
45
64
 
46
65
  import { VERSION } from './version.js';
47
66
 
@@ -57,10 +76,21 @@ Usage:
57
76
  --wait [seconds] polls until approved (default 120s), renewing
58
77
  an expired code in place and printing the new one.
59
78
  brass logout End the stored sign-in for this environment, here and on the server.
60
- brass status [dir] Report the credential + app state and the one command to run next.
79
+ brass browser-session --return-to <url> [--app <appId>] [--json]
80
+ Print a URL that opens <url> signed in as you, for a browser
81
+ you drive. The URL is single-use and expires in two minutes.
82
+ brass status [dir] [--app <appId>]
83
+ Report the credential + app state and the one command to run next.
61
84
  brass publish [dir] Build output in [dir] (default: dist) is deployed to the app's hosting.
62
85
  brass schema pull --doc <docId> [--out brass-app.json]
63
86
  Fetch a document's schema and write it into a manifest, verbatim.
87
+ brass contents --doc <docId> [--out contents.json | --stdout] [--stream <name>] [--branch <id>]
88
+ Write everything a document holds as JSON: every
89
+ root container, read whole. The whole document,
90
+ not a sample, so every record type it carries is
91
+ in the file. A document still being imported is
92
+ waited out (--wait <seconds> moves the deadline,
93
+ default 900; --no-wait reads once and fails).
64
94
  brass agents pull [--out AGENTS.md | --stdout] [--org <organizationId>]
65
95
  Write your organization's agent instructions (its
66
96
  AGENTS.md / CLAUDE.md) to a file, or --stdout to print
@@ -71,12 +101,24 @@ Usage:
71
101
  Authentication:
72
102
  Run 'brass login' to sign in with your browser (needed for schema pull),
73
103
  or set BRASS_SERVICE_TOKEN to a service token minted in the dashboard
74
- (org Settings -> Service tokens) for CI, or pass --token <token>.
104
+ (org Settings -> Access tokens) for CI, or pass --token <token>.
75
105
 
76
106
  Common flags:
77
107
  --token <token> Service token to authenticate with.
78
108
  --json Emit the machine-readable result on stdout.
79
109
 
110
+ Status flags:
111
+ --app <appId> Report on a specific app (else .brass/project.json / BRASS_APP_ID).
112
+
113
+ Sign-in flags:
114
+ --app <appId> Approve an app you do not own for the sign-in as
115
+ well, so 'brass browser-session' needs no second
116
+ approval. An app you own never does.
117
+
118
+ Browser-session flags:
119
+ --return-to <url> Where the browser lands, on the app's redirect allowlist.
120
+ --app <appId> The app to sign in to (else .brass/project.json / BRASS_APP_ID).
121
+
80
122
  Publish flags:
81
123
  --app <appId> Publish to a specific app (else .brass/project.json / BRASS_APP_ID).
82
124
  --name <name> Name for the app when creating one on first publish.
@@ -162,6 +204,12 @@ export async function run(argv: readonly string[]): Promise<number> {
162
204
  // one is obtained); everything else needs a resolved credential.
163
205
  if (command === 'login') return await runLogin(parsed, log);
164
206
  if (command === 'logout') return await runLogout(parsed, log);
207
+ // Reads the stored device pointer directly rather than through
208
+ // `buildContext`: what the handoff route takes is that credential, not an
209
+ // access token minted from it.
210
+ if (command === 'browser-session') {
211
+ return await runBrowserSession(parsed, log, boolFlag(parsed, 'json'));
212
+ }
165
213
  // status runs before a credential is required: reporting "no credential"
166
214
  // (and the next step to obtain one) is a first-class outcome, not an error.
167
215
  if (command === 'status') return await runStatus(parsed, log);
@@ -193,7 +241,7 @@ type Context = CommandContext;
193
241
 
194
242
  // The commands that need a resolved credential; `login` / `logout` / `status`
195
243
  // are answered above without one.
196
- const CREDENTIALED_COMMANDS = ['publish', 'schema', 'agents', 'whoami'] as const;
244
+ const CREDENTIALED_COMMANDS = ['publish', 'schema', 'contents', 'agents', 'whoami'] as const;
197
245
  type CredentialedCommand = (typeof CREDENTIALED_COMMANDS)[number];
198
246
 
199
247
  function isCredentialedCommand(value: string): value is CredentialedCommand {
@@ -212,6 +260,8 @@ function planCommand(command: CredentialedCommand, parsed: ParsedArgs): CommandP
212
260
  return planPublish(parsed);
213
261
  case 'schema':
214
262
  return planSchema(parsed);
263
+ case 'contents':
264
+ return planContents(parsed);
215
265
  case 'agents':
216
266
  return planAgents(parsed);
217
267
  case 'whoami':
@@ -283,13 +333,18 @@ async function resolveAuth(
283
333
  });
284
334
  if (credential === null) {
285
335
  throw new Error(
286
- 'No credential. Run `brass login`, or set BRASS_SERVICE_TOKEN (mint one in the dashboard: org Settings -> Service tokens), or pass --token.',
336
+ 'No credential. Run `brass login`, or set BRASS_SERVICE_TOKEN (mint one in the dashboard: org Settings -> Access tokens), or pass --token.',
287
337
  );
288
338
  }
289
339
  const auth =
290
340
  credential.kind === 'service'
291
341
  ? serviceTokenAuth(credential.token)
292
- : sessionAuth(origins.authBaseUrl, credential.sid);
342
+ : // The origin the session was minted on, so a command refreshes it
343
+ // there. The stored profile is keyed on the api host, so an
344
+ // invocation naming `--api-url` alone lands on a session whose auth
345
+ // origin the flags do not carry; refreshing against the default one
346
+ // instead answers 401 and reads as the sign-in having gone stale.
347
+ sessionAuth(credential.authBaseUrl ?? origins.authBaseUrl, credential.sid);
293
348
  return { auth, credentialKind: credential.kind };
294
349
  }
295
350
 
@@ -300,13 +355,17 @@ async function runLogin(parsed: ParsedArgs, log: Logger): Promise<number> {
300
355
  // interactive flow.
301
356
  const start = boolFlag(parsed, 'start');
302
357
  const check = boolFlag(parsed, 'check');
303
- const waitSeconds = parseWaitFlag(parsed);
358
+ const waitSeconds = parseWaitFlag(parsed, DEFAULT_WAIT_SECONDS);
304
359
  if (start && check) throw new Error('Pass one of --start or --check.');
360
+ // The app this sign-in should also approve, so the human's one click covers
361
+ // the machine and the app it is about to hand a browser.
362
+ const targetAppId = stringFlag(parsed, 'app');
305
363
  if (start) {
306
364
  const code = await loginStart({
307
365
  authBaseUrl: origins.authBaseUrl,
308
366
  profile,
309
367
  log,
368
+ ...(targetAppId !== undefined ? { targetAppId } : {}),
310
369
  ...(boolFlag(parsed, 'new') ? { force: true } : {}),
311
370
  ...(waitSeconds !== undefined ? { resultFollows: true } : {}),
312
371
  });
@@ -320,7 +379,10 @@ async function runLogin(parsed: ParsedArgs, log: Logger): Promise<number> {
320
379
  }
321
380
  // The RFC 8628 device grant: open the approval page (code prefilled), print
322
381
  // the URL + code as a fallback for a headless box, and poll until approval.
323
- const result = await loginDevice({ authBaseUrl: origins.authBaseUrl });
382
+ const result = await loginDevice({
383
+ authBaseUrl: origins.authBaseUrl,
384
+ ...(targetAppId !== undefined ? { targetAppId } : {}),
385
+ });
324
386
  await writeStoredCredential(profile, {
325
387
  session: { sid: result.sessionToken, authBaseUrl: origins.authBaseUrl },
326
388
  });
@@ -372,6 +434,103 @@ async function runLogout(parsed: ParsedArgs, log: Logger): Promise<number> {
372
434
  return revoked ? 0 : 1;
373
435
  }
374
436
 
437
+ // `brass browser-session`: turn this machine's sign-in into a URL that signs a
438
+ // browser in as the same human, so an agent that just built an app can open it
439
+ // and drive it rather than reporting it verified by types.
440
+ //
441
+ // The URL is what the command is for, so in the plain form it goes to stdout
442
+ // alone and the guidance to stderr, and `--json` puts it in the result object
443
+ // instead. Either way it is a credential with a two-minute life: it belongs in
444
+ // the browser being driven, not in a log or a commit.
445
+ async function runBrowserSession(
446
+ parsed: ParsedArgs,
447
+ log: Logger,
448
+ json: boolean,
449
+ ): Promise<number> {
450
+ const { origins, profile } = resolveBase(parsed);
451
+ const returnTo = stringFlag(parsed, 'return-to');
452
+ if (returnTo === undefined) {
453
+ throw new Error(
454
+ 'brass browser-session requires --return-to <url> (where the browser should land)',
455
+ );
456
+ }
457
+ const state = await readProjectState(process.cwd());
458
+ const flagApp = stringFlag(parsed, 'app');
459
+ const envApp = process.env['BRASS_APP_ID'];
460
+ const appId = resolveAppId({
461
+ ...(flagApp !== undefined ? { flagApp } : {}),
462
+ ...(envApp !== undefined ? { envApp } : {}),
463
+ state,
464
+ profile,
465
+ });
466
+ if (appId === null) {
467
+ throw new Error(
468
+ 'No app to sign in to. Pass --app <appId>, or run this from a directory `brass publish` has written .brass/project.json in.',
469
+ );
470
+ }
471
+
472
+ // The device pointer itself, not an access token minted from it: the handoff
473
+ // route authenticates the machine, and only that credential names one. A
474
+ // service token belongs to an organization rather than to a person, so it
475
+ // has no session for a browser to be handed.
476
+ const file = await readCredentialsFile();
477
+ const session = file?.credentials[profile]?.session;
478
+ if (session === undefined) {
479
+ throw new Error(
480
+ 'No Brass sign-in on this machine. Run `brass login` (a service token cannot be handed to a browser).',
481
+ );
482
+ }
483
+
484
+ const outcome = await postDeviceAppSession(
485
+ session.authBaseUrl ?? origins.authBaseUrl,
486
+ session.sid,
487
+ { appId, returnTo },
488
+ );
489
+ if (outcome.state === 'needs_approval') {
490
+ // Recorded so `brass status` can report what is outstanding. A caller that
491
+ // relays the URL and then asks status what to do next is otherwise told
492
+ // the app is ready, which is true of publishing and false of the thing it
493
+ // was doing.
494
+ await writePendingApproval(profile, {
495
+ appId,
496
+ approvalUrl: outcome.approvalUrl,
497
+ authBaseUrl: session.authBaseUrl ?? origins.authBaseUrl,
498
+ expiresAt: Date.now() + outcome.expiresIn * 1000,
499
+ });
500
+ // Through the same frame the sign-in's approval prints in, and with the
501
+ // same relay note: this is the same request with a different subject, and
502
+ // it reaches the human only if whoever runs this passes it on.
503
+ log.info(
504
+ `\n${renderApprovalPrompt({
505
+ heading: ` To open ${appId} in a browser, it needs approving for this machine.`,
506
+ url: outcome.approvalUrl,
507
+ relaying: true,
508
+ closing:
509
+ 'The person approving must be signed in to Brass as the account this ' +
510
+ 'machine is signed in as.\nThen run this command again.',
511
+ })}`,
512
+ );
513
+ log.result({ state: 'needs_approval', app_id: appId, approval_url: outcome.approvalUrl });
514
+ return 1;
515
+ }
516
+ await writePendingApproval(profile, null);
517
+ if (json) {
518
+ log.result({
519
+ state: 'ready',
520
+ url: outcome.url,
521
+ app_id: appId,
522
+ return_to: returnTo,
523
+ expires_in_seconds: outcome.expiresIn,
524
+ });
525
+ } else {
526
+ log.info(
527
+ `Open this in the browser you are driving, within ${outcome.expiresIn} seconds. It signs in once and cannot be reused.`,
528
+ );
529
+ log.write(`${outcome.url}\n`);
530
+ }
531
+ return 0;
532
+ }
533
+
375
534
  async function runStatus(parsed: ParsedArgs, log: Logger): Promise<number> {
376
535
  const { origins, profile } = resolveBase(parsed);
377
536
 
@@ -393,7 +552,8 @@ async function runStatus(parsed: ParsedArgs, log: Logger): Promise<number> {
393
552
  origins.apiBaseUrl,
394
553
  credential.kind === 'service'
395
554
  ? serviceTokenAuth(credential.token)
396
- : sessionAuth(origins.authBaseUrl, credential.sid),
555
+ : // The origin the session was minted on, as `resolveAuth` takes.
556
+ sessionAuth(credential.authBaseUrl ?? origins.authBaseUrl, credential.sid),
397
557
  );
398
558
 
399
559
  // What `publish` would target: the same app-id and manifest-name resolution
@@ -409,13 +569,28 @@ async function runStatus(parsed: ParsedArgs, log: Logger): Promise<number> {
409
569
  state,
410
570
  profile,
411
571
  });
412
- const manifest = await readManifest(stringFlag(parsed, 'manifest') ?? 'brass-app.json');
572
+ const manifestFlag = stringFlag(parsed, 'manifest');
573
+ // `status` reports rather than acts, so a manifest that cannot be parsed is
574
+ // named and the rest of the report still runs. Publishing that same tree
575
+ // fails on it, which is the point at which it has to be fixed.
576
+ let manifest: AppManifest | undefined;
577
+ try {
578
+ manifest = (
579
+ await loadFirstManifest(
580
+ cwd,
581
+ manifestFlag !== undefined ? [manifestFlag] : publishManifestCandidates(dir),
582
+ )
583
+ )?.manifest;
584
+ } catch (err) {
585
+ log.warn(err instanceof Error ? err.message : String(err));
586
+ }
413
587
  const manifestName =
414
588
  typeof manifest?.name === 'string' && manifest.name.trim() !== ''
415
589
  ? manifest.name.trim()
416
590
  : null;
417
591
 
418
592
  const pending = await readPendingLogin(profile);
593
+ const pendingApproval = await readPendingApproval(profile);
419
594
  const result = await status({
420
595
  api,
421
596
  profile,
@@ -432,6 +607,7 @@ async function runStatus(parsed: ParsedArgs, log: Logger): Promise<number> {
432
607
  verificationUrl: pending.verificationUriComplete ?? pending.verificationUri,
433
608
  expiresAt: pending.expiresAt,
434
609
  },
610
+ pendingApproval,
435
611
  });
436
612
  log.result(result);
437
613
  return 0;
@@ -445,7 +621,7 @@ function planPublish(parsed: ParsedArgs): CommandPlan {
445
621
  const org = stringFlag(parsed, 'org');
446
622
  const slug = stringFlag(parsed, 'slug');
447
623
  const clientToken = stringFlag(parsed, 'client-token');
448
- const manifestPath = stringFlag(parsed, 'manifest') ?? 'brass-app.json';
624
+ const manifestPath = stringFlag(parsed, 'manifest');
449
625
  const visibility = parseVisibilityFlag(stringFlag(parsed, 'visibility'));
450
626
  const requireAccess = parseGateFlag(stringFlag(parsed, 'gate'));
451
627
  return async (ctx): Promise<unknown> => {
@@ -458,7 +634,7 @@ function planPublish(parsed: ParsedArgs): CommandPlan {
458
634
  });
459
635
  return publish(ctx, {
460
636
  dir,
461
- manifestPath,
637
+ ...(manifestPath !== undefined ? { manifestPath } : {}),
462
638
  ...(appId !== null ? { appId } : {}),
463
639
  ...(name !== undefined ? { name } : {}),
464
640
  ...(org !== undefined ? { organizationId: org } : {}),
@@ -476,11 +652,19 @@ function planPublish(parsed: ParsedArgs): CommandPlan {
476
652
  // so a second check resumes the same sign-in.
477
653
  const DEFAULT_WAIT_SECONDS = 120;
478
654
  const MAX_WAIT_SECONDS = 3600;
479
-
480
- function parseWaitFlag(parsed: ParsedArgs): number | undefined {
655
+ // `brass contents` waits by default, so its `--wait` only moves the budget.
656
+ // The default is the extraction worker's own ceiling: past it the run has
657
+ // stopped rather than slowed.
658
+ const CONTENTS_WAIT_SECONDS = 900;
659
+
660
+ // `--wait` with no value means "the command's own default deadline", which
661
+ // differs per command, so the default is the caller's to name. `undefined`
662
+ // back means the flag was absent, which each command reads for itself: the
663
+ // sign-in poll does not wait at all, and a document read waits anyway.
664
+ function parseWaitFlag(parsed: ParsedArgs, bareValue: number): number | undefined {
481
665
  const raw = parsed.flags['wait'];
482
666
  if (raw === undefined) return undefined;
483
- if (raw === true) return DEFAULT_WAIT_SECONDS;
667
+ if (raw === true) return bareValue;
484
668
  const seconds = Number(raw);
485
669
  if (!Number.isInteger(seconds) || seconds <= 0 || seconds > MAX_WAIT_SECONDS) {
486
670
  throw new Error(`Invalid --wait "${raw}" (expected whole seconds, 1 to ${MAX_WAIT_SECONDS})`);
@@ -509,14 +693,52 @@ function parseGateFlag(value: string | undefined): boolean | undefined {
509
693
  return value === 'on';
510
694
  }
511
695
 
696
+ function planContents(parsed: ParsedArgs): CommandPlan {
697
+ const docId = stringFlag(parsed, 'doc');
698
+ if (docId === undefined) throw new Error('brass contents requires --doc <docId>');
699
+ const toStdout = boolFlag(parsed, 'stdout');
700
+ const outPath = stringFlag(parsed, 'out');
701
+ // Both name where the JSON goes, so together neither answer is the one the
702
+ // caller gets.
703
+ if (toStdout && outPath !== undefined) {
704
+ throw new Error('Pass one of --stdout or --out, not both');
705
+ }
706
+ // A document whose content is still being prepared is waited out rather
707
+ // than refused, because this command's own join is what starts that work.
708
+ // The two flags name the same budget from opposite ends, so together
709
+ // neither answer is the one the caller gets.
710
+ const noWait = boolFlag(parsed, 'no-wait');
711
+ const waitSeconds = parseWaitFlag(parsed, CONTENTS_WAIT_SECONDS);
712
+ if (noWait && waitSeconds !== undefined) {
713
+ throw new Error('Pass one of --wait or --no-wait, not both');
714
+ }
715
+ const opts = {
716
+ docId,
717
+ stream: stringFlag(parsed, 'stream'),
718
+ branch: stringFlag(parsed, 'branch'),
719
+ outPath: toStdout ? null : (outPath ?? 'contents.json'),
720
+ waitMs: noWait ? 0 : (waitSeconds ?? CONTENTS_WAIT_SECONDS) * 1000,
721
+ };
722
+ return (ctx): Promise<unknown> => documentContents(ctx, opts);
723
+ }
724
+
512
725
  function planSchema(parsed: ParsedArgs): CommandPlan {
513
726
  if (parsed.positionals[1] !== 'pull') {
514
727
  throw new Error('Usage: brass schema pull --doc <docId> [--out brass-app.json]');
515
728
  }
516
729
  const docId = stringFlag(parsed, 'doc');
517
730
  if (docId === undefined) throw new Error('brass schema pull requires --doc <docId>');
518
- const outPath = stringFlag(parsed, 'out') ?? 'brass-app.json';
519
- return (ctx): Promise<unknown> => schemaPull(ctx, { docId, outPath });
731
+ const outFlag = stringFlag(parsed, 'out');
732
+ return async (ctx): Promise<unknown> => {
733
+ // Merge into the manifest the repo already keeps rather than writing a
734
+ // second one beside it: a pulled schema in a file the bundle never serves
735
+ // is a schema the platform never reads.
736
+ const outPath =
737
+ outFlag ??
738
+ (await firstExistingManifestPath(ctx.cwd, sourceManifestCandidates())) ??
739
+ MANIFEST_FILENAME;
740
+ return schemaPull(ctx, { docId, outPath });
741
+ };
520
742
  }
521
743
 
522
744
  function planAgents(parsed: ParsedArgs): CommandPlan {