@clossys/launcher 0.1.1 → 0.1.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/core.ts CHANGED
@@ -3,9 +3,13 @@ import { fileURLToPath } from "node:url";
3
3
  import type {
4
4
  CommandResult,
5
5
  CwdObservation,
6
+ DependencyBucket,
6
7
  HubDocument,
7
8
  HubHealthReport,
8
9
  InventoryObservation,
10
+ InventoryValidationEntry,
11
+ InventoryValidationReport,
12
+ PinFinding,
9
13
  WorkspaceApplyResult,
10
14
  WorkspaceDecision,
11
15
  WorkspaceHost,
@@ -20,6 +24,13 @@ export const WORKSPACE_MARKER_REL = ".clossys/workspace.json";
20
24
  export const WORKSPACE_INVENTORY_REL = ".clossys/inventory.json";
21
25
  export const ADVISOR_PACKAGE = "@clossys/advisor";
22
26
 
27
+ const DEPENDENCY_BUCKETS: readonly DependencyBucket[] = [
28
+ "dependencies",
29
+ "devDependencies",
30
+ "optionalDependencies",
31
+ "peerDependencies",
32
+ ];
33
+
23
34
  const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
24
35
  const REPO = /^[A-Za-z0-9._-]+$/;
25
36
  const SKELETON_FILES = [
@@ -89,6 +100,34 @@ export function parseGitHubRemote(url: string): { owner: string; repository: str
89
100
  }
90
101
  }
91
102
 
103
+ function readJson(host: WorkspaceHost, path: string): unknown {
104
+ const raw = host.readText(path);
105
+ if (raw === null) return undefined;
106
+ try {
107
+ return JSON.parse(raw) as unknown;
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ }
112
+
113
+ /** Tiny semver-ish compare of `a` versus `b`: -1 older, 0 equal, 1 newer. Unparseable versions are indeterminate (null). */
114
+ function compareVersions(a: string, b: string): -1 | 0 | 1 | null {
115
+ const parse = (version: string): readonly number[] | null => {
116
+ const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/);
117
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
118
+ };
119
+ const left = parse(a);
120
+ const right = parse(b);
121
+ if (!left || !right) return null;
122
+ for (let index = 0; index < 3; index += 1) {
123
+ const l = left[index] ?? 0;
124
+ const r = right[index] ?? 0;
125
+ if (l < r) return -1;
126
+ if (l > r) return 1;
127
+ }
128
+ return 0;
129
+ }
130
+
92
131
  /** True when the marker is a v1 account-hub document. */
93
132
  export function isHubDocument(value: unknown): value is HubDocument {
94
133
  if (!isRecord(value) || value.schemaVersion !== 1 || value.kind !== "account-hub") return false;
@@ -159,6 +198,12 @@ function commandAvailable(host: WorkspaceHost, command: string): boolean {
159
198
  return result.status === 0;
160
199
  }
161
200
 
201
+ /** True when the tree already pins Advisor in some bucket, so adopt would not need a new version. */
202
+ export function hasAdvisorPin(manifest: unknown): boolean {
203
+ if (!isRecord(manifest)) return false;
204
+ return DEPENDENCY_BUCKETS.some((bucket) => clossysNames(manifest[bucket], new Set()) !== undefined);
205
+ }
206
+
162
207
  /** Collects GitHub owner, cwd shape, default-hub presence, and the public Advisor pin. */
163
208
  export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
164
209
  const cwd = host.cwd;
@@ -196,9 +241,14 @@ export function observeWorkspace(host: WorkspaceHost): WorkspaceObservation {
196
241
  const viewed = host.run("gh", ["repo", "view", `${ownerGuess}/${DEFAULT_REPOSITORY_NAME}`, "--json", "name"]);
197
242
  if (viewed.status === 0) remoteDefaultHub = { owner: ownerGuess, repository: DEFAULT_REPOSITORY_NAME };
198
243
  }
199
- const viewedAdvisor = host.run("npm", ["view", ADVISOR_PACKAGE, "version"]);
200
- const version = viewedAdvisor.stdout.trim();
201
- if (viewedAdvisor.status === 0 && /^\d+\.\d+\.\d+$/.test(version)) advisorVersion = version;
244
+ // Skip the registry read when the tree already pins Advisor in some bucket:
245
+ // adopt leaves an existing pin alone, so no live version is needed to plan it.
246
+ const manifestPinsAdvisor = hasAdvisorPin(readJson(host, join(cwd, "package.json")));
247
+ if (!manifestPinsAdvisor) {
248
+ const viewedAdvisor = host.run("npm", ["view", ADVISOR_PACKAGE, "version"]);
249
+ const version = viewedAdvisor.stdout.trim();
250
+ if (viewedAdvisor.status === 0 && /^\d+\.\d+\.\d+$/.test(version)) advisorVersion = version;
251
+ }
202
252
 
203
253
  const cwdObservation: CwdObservation = {
204
254
  absolutePath: cwd,
@@ -245,19 +295,43 @@ function resolveOwner(observation: WorkspaceObservation, host: WorkspaceHost): {
245
295
  * Decides create, resume, or adopt from a cwd observation.
246
296
  * Appointing means: run this from the GitHub repository that should own the hub.
247
297
  */
298
+ function readInventoryRepositories(host: WorkspaceHost, source: string, label: string): readonly string[] {
299
+ const raw = host.readText(source);
300
+ if (raw === null) return [];
301
+ try {
302
+ const parsed: unknown = JSON.parse(raw);
303
+ if (!isRecord(parsed) || !Array.isArray(parsed.repositories)) return [];
304
+ return parsed.repositories.map((entry) => {
305
+ if (isRecord(entry) && isText(entry.id)) return entry.id.trim();
306
+ return "";
307
+ });
308
+ } catch {
309
+ throw new Error(`${label} is not readable inventory JSON (schemaVersion 1, repositories array)`);
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Resolves which repositories the adopt plan inventories. When the on-disk hub
315
+ * inventory is populated AND --inventory is supplied, the two are merged by id:
316
+ * on-disk entries keep their order, ids not already on disk are appended in
317
+ * supplied-file order, and the first occurrence of an id wins; the plan carries
318
+ * the merged ids for applyWorkspacePlan to write. When both are empty or
319
+ * missing, adopt refuses rather than appointing an inventory-less hub.
320
+ */
248
321
  function resolveAdoptInventory(
249
322
  host: WorkspaceHost,
250
323
  cwd: CwdObservation,
251
324
  inventoryPath: string | undefined,
252
- ): { inventorySource?: string } | WorkspaceRefusal {
253
- if (cwd.inventory?.status === "populated") return {};
325
+ ): { inventorySource?: string; mergedInventoryIds?: readonly string[] } | WorkspaceRefusal {
254
326
  const trimmed = inventoryPath?.trim();
255
- if (!trimmed) {
327
+ const onDiskPopulated = cwd.inventory?.status === "populated";
328
+ if (!onDiskPopulated && !trimmed) {
256
329
  return refuse(
257
330
  "violated",
258
331
  "appointing requires a populated generated hub inventory (packed template skeleton/.clossys/inventory.json; the generated path does not ship), or --inventory <path> to a populated inventory document",
259
332
  );
260
333
  }
334
+ if (!trimmed) return {};
261
335
  const resolved = resolve(cwd.absolutePath, trimmed);
262
336
  const imported = inspectInventory(host.readText(resolved));
263
337
  if (imported.status !== "populated") {
@@ -266,7 +340,22 @@ function resolveAdoptInventory(
266
340
  "--inventory must point at a populated inventory document (schemaVersion 1, nonempty repositories)",
267
341
  );
268
342
  }
269
- return { inventorySource: resolved };
343
+ if (!onDiskPopulated) return { inventorySource: resolved };
344
+ const merged: string[] = [];
345
+ const seen = new Set<string>();
346
+ for (const id of readInventoryRepositories(host, join(cwd.absolutePath, WORKSPACE_INVENTORY_REL), "the on-disk hub inventory")) {
347
+ if (id !== "" && !seen.has(id)) {
348
+ seen.add(id);
349
+ merged.push(id);
350
+ }
351
+ }
352
+ for (const id of readInventoryRepositories(host, resolved, "--inventory")) {
353
+ if (id !== "" && !seen.has(id)) {
354
+ seen.add(id);
355
+ merged.push(id);
356
+ }
357
+ }
358
+ return { mergedInventoryIds: merged };
270
359
  }
271
360
 
272
361
  export function planWorkspace(
@@ -275,6 +364,20 @@ export function planWorkspace(
275
364
  options: { inventoryPath?: string } = {},
276
365
  ): WorkspaceDecision {
277
366
  const { cwd } = observation;
367
+ const envOwner = observation.envOwner ?? (host.env.CLOSSYS_OWNER?.trim() || undefined);
368
+ if (
369
+ envOwner !== undefined &&
370
+ !cwd.looksLikeFoundry &&
371
+ cwd.hub === undefined &&
372
+ cwd.git &&
373
+ cwd.githubOwner !== undefined &&
374
+ cwd.githubOwner !== envOwner
375
+ ) {
376
+ return refuse(
377
+ "violated",
378
+ `CLOSSYS_OWNER is "${envOwner}" but the github.com origin here is owned by "${cwd.githubOwner}"; refusing to appoint a marker for the wrong account`,
379
+ );
380
+ }
278
381
  if (cwd.looksLikeFoundry) {
279
382
  return refuse(
280
383
  "violated",
@@ -303,6 +406,7 @@ export function planWorkspace(
303
406
  directory: cwd.absolutePath,
304
407
  advisorVersion: observation.advisorVersion,
305
408
  ...(imported.inventorySource === undefined ? {} : { inventorySource: imported.inventorySource }),
409
+ ...(imported.mergedInventoryIds === undefined ? {} : { mergedInventoryIds: imported.mergedInventoryIds }),
306
410
  };
307
411
  }
308
412
  if (cwd.git) {
@@ -404,9 +508,8 @@ function mergeAdvisorPin(
404
508
  throw new Error("existing package.json is unreadable JSON");
405
509
  }
406
510
  const extra = new Set<string>();
407
- const inDependencies = clossysNames(manifest.dependencies, extra);
408
- const inDevDependencies = clossysNames(manifest.devDependencies, extra);
409
- if (inDependencies !== undefined || inDevDependencies !== undefined) return;
511
+ const pinnedSomewhere = DEPENDENCY_BUCKETS.some((bucket) => clossysNames(manifest[bucket], extra) !== undefined);
512
+ if (pinnedSomewhere) return;
410
513
  const devDependencies = isRecord(manifest.devDependencies) ? { ...manifest.devDependencies } : {};
411
514
  devDependencies[ADVISOR_PACKAGE] = advisorVersion;
412
515
  manifest.devDependencies = devDependencies;
@@ -423,7 +526,30 @@ function copySkeleton(host: WorkspaceHost, skeletonRoot: string, plan: Workspace
423
526
  writeSkeletonFile(host, plan.directory, "CLAUDE.md", CONSUMER_CLAUDE_MD);
424
527
  }
425
528
 
529
+ /** Refuses adopt when the working tree has uncommitted changes; names a non-github.com remote host. */
530
+ function assertCleanTree(host: WorkspaceHost, directory: string): void {
531
+ const status = host.run("git", ["status", "--porcelain"], { cwd: directory });
532
+ if (status.status === 0 && status.stdout.trim() !== "") {
533
+ const origin = host.run("git", ["remote", "get-url", "origin"], { cwd: directory }).stdout.trim();
534
+ let where = "this checkout";
535
+ if (origin !== "") {
536
+ try {
537
+ const parsed = new URL(origin);
538
+ if (parsed.protocol === "ssh:" || parsed.protocol === "https:" || parsed.protocol === "http:") {
539
+ where = `the repository on ${parsed.hostname}`;
540
+ }
541
+ } catch {
542
+ /* ssh form or unparsable; keep the generic host */
543
+ }
544
+ }
545
+ throw new Error(
546
+ `${where} has uncommitted changes (git status --porcelain is nonempty); commit or stash them before appointing it as the account hub`,
547
+ );
548
+ }
549
+ }
550
+
426
551
  function adoptHubFiles(host: WorkspaceHost, skeletonRoot: string, plan: WorkspacePlan & { advisorVersion: string }): void {
552
+ assertCleanTree(host, plan.directory);
427
553
  const marker = {
428
554
  schemaVersion: 1,
429
555
  kind: "account-hub",
@@ -431,7 +557,15 @@ function adoptHubFiles(host: WorkspaceHost, skeletonRoot: string, plan: Workspac
431
557
  repository: `${plan.owner}/${plan.repository}`,
432
558
  };
433
559
  writeSkeletonFile(host, plan.directory, WORKSPACE_MARKER_REL, `${JSON.stringify(marker, null, 2)}\n`);
434
- if ("inventorySource" in plan && typeof plan.inventorySource === "string") {
560
+ if ("mergedInventoryIds" in plan && Array.isArray(plan.mergedInventoryIds)) {
561
+ const document = { schemaVersion: 1, repositories: plan.mergedInventoryIds.map((id) => ({ id })) };
562
+ writeSkeletonFile(
563
+ host,
564
+ plan.directory,
565
+ WORKSPACE_INVENTORY_REL,
566
+ `${JSON.stringify(document, null, 2)}\n`,
567
+ );
568
+ } else if ("inventorySource" in plan && typeof plan.inventorySource === "string") {
435
569
  const raw = host.readText(plan.inventorySource);
436
570
  if (raw === null || inspectInventory(raw).status !== "populated") {
437
571
  throw new Error("inventory source is not a populated inventory document");
@@ -461,55 +595,118 @@ function requireZero(result: CommandResult, label: string): void {
461
595
  }
462
596
  }
463
597
 
464
- /** Read-only pin and inventory report. Does not install or uninstall. */
598
+ /**
599
+ * Read-only pin and inventory report. Does not install or uninstall. Scans all
600
+ * four dependency buckets; grades each pinned advisor version against the live
601
+ * registry version, marking a pin older than live as a stale-pin finding and a
602
+ * degraded report.
603
+ */
465
604
  export function reportHubHealth(host: WorkspaceHost, directory: string, liveAdvisorVersion?: string): HubHealthReport {
466
605
  const extra = new Set<string>();
467
- let dependencies: string | undefined;
468
- let devDependencies: string | undefined;
606
+ const pins: Partial<Record<DependencyBucket, string>> = {};
469
607
  const manifestRaw = host.readText(join(directory, "package.json"));
470
608
  if (manifestRaw !== null) {
471
609
  try {
472
610
  const parsed: unknown = JSON.parse(manifestRaw);
473
611
  if (isRecord(parsed)) {
474
- dependencies = clossysNames(parsed.dependencies, extra);
475
- devDependencies = clossysNames(parsed.devDependencies, extra);
612
+ for (const bucket of DEPENDENCY_BUCKETS) {
613
+ const pin = clossysNames(parsed[bucket], extra);
614
+ if (pin !== undefined) pins[bucket] = pin;
615
+ }
476
616
  }
477
617
  } catch {
478
618
  /* unreadable manifest is reported as missing pins */
479
619
  }
480
620
  }
621
+ const pinFindings: PinFinding[] = Object.entries(pins).flatMap(([bucket, pinned]): PinFinding[] => {
622
+ if (liveAdvisorVersion === undefined) return [];
623
+ const comparison = compareVersions(pinned, liveAdvisorVersion);
624
+ if (comparison === null) {
625
+ return [{ bucket: bucket as DependencyBucket, pinned, grade: "indeterminate", note: `cannot compare ${pinned} with live ${liveAdvisorVersion}` }];
626
+ }
627
+ return comparison < 0
628
+ ? [{ bucket: bucket as DependencyBucket, pinned, grade: "stale", note: `pinned ${pinned} is older than live ${liveAdvisorVersion}` }]
629
+ : [];
630
+ });
481
631
  return {
482
632
  marker: readHub(host, directory) === undefined ? "missing" : "present",
483
633
  inventory: inspectInventory(host.readText(join(directory, WORKSPACE_INVENTORY_REL))),
484
634
  advisorPin: {
485
- ...(dependencies === undefined ? {} : { dependencies }),
486
- ...(devDependencies === undefined ? {} : { devDependencies }),
635
+ ...(pins.dependencies === undefined ? {} : { dependencies: pins.dependencies }),
636
+ ...(pins.devDependencies === undefined ? {} : { devDependencies: pins.devDependencies }),
637
+ ...(pins.optionalDependencies === undefined ? {} : { optionalDependencies: pins.optionalDependencies }),
638
+ ...(pins.peerDependencies === undefined ? {} : { peerDependencies: pins.peerDependencies }),
487
639
  ...(liveAdvisorVersion === undefined ? {} : { live: liveAdvisorVersion }),
488
640
  },
489
- dualPin: dependencies !== undefined && devDependencies !== undefined,
641
+ dualPin: Object.values(pins).filter((value) => value !== undefined).length > 1,
490
642
  extraClossys: [...extra].sort(),
643
+ pinFindings,
644
+ degraded: pinFindings.some((finding) => finding.grade === "stale"),
491
645
  };
492
646
  }
493
647
 
494
648
  export function formatHubHealth(report: HubHealthReport): string {
495
649
  const pinParts: string[] = [];
496
- if (report.advisorPin.dependencies !== undefined) pinParts.push(`dependencies ${report.advisorPin.dependencies}`);
497
- if (report.advisorPin.devDependencies !== undefined) pinParts.push(`devDependencies ${report.advisorPin.devDependencies}`);
650
+ for (const bucket of DEPENDENCY_BUCKETS) {
651
+ const pinned = report.advisorPin[bucket];
652
+ if (pinned !== undefined) pinParts.push(`${bucket} ${pinned}`);
653
+ }
498
654
  const pin = pinParts.length === 0 ? "missing" : pinParts.join(" and ");
499
655
  const live = report.advisorPin.live === undefined ? "" : `; live ${report.advisorPin.live}`;
500
656
  const extra = report.extraClossys.length === 0 ? "none" : report.extraClossys.join(", ");
501
657
  const inventory =
502
658
  report.inventory.status === "populated" ? `populated (${report.inventory.count})` : report.inventory.status;
659
+ const findings = report.pinFindings.map((finding) =>
660
+ finding.note !== undefined ? `${finding.bucket} ${finding.note}` : `${finding.bucket} ${finding.grade}`,
661
+ );
662
+ const findingLine = findings.length === 0 ? "none" : findings.join("; ");
503
663
  return [
504
664
  `hub marker: ${report.marker}`,
505
665
  `inventory: ${inventory}`,
506
666
  `advisor pin: ${pin}${live}`,
507
667
  `dual pin: ${report.dualPin ? "yes" : "no"}`,
508
668
  `extra @clossys/*: ${extra}`,
669
+ `pin findings: ${findingLine}`,
670
+ `degraded: ${report.degraded ? "yes" : "no"}`,
509
671
  `health: ${JSON.stringify(report)}`,
510
672
  ].join("\n");
511
673
  }
512
674
 
675
+ /**
676
+ * Read-only inventory validation: runs `gh repo view --json name` for each
677
+ * repository id, batched. Tolerates a missing or failing `gh` by skipping with
678
+ * a note; marks ids whose check fails as unknown. Never mutates the inventory.
679
+ */
680
+ export function checkInventoryEntries(host: WorkspaceHost, directory: string): InventoryValidationReport {
681
+ const raw = host.readText(join(directory, WORKSPACE_INVENTORY_REL));
682
+ const observation = inspectInventory(raw);
683
+ if (observation.status !== "populated") {
684
+ return { entries: [], skipped: true, note: `inventory is ${observation.status}; nothing to validate` };
685
+ }
686
+ const available = commandAvailable(host, "gh");
687
+ if (!available) {
688
+ return { entries: [], skipped: true, note: "`gh` is unavailable; inventory ids were not validated" };
689
+ }
690
+ const ids = readInventoryRepositories(host, join(directory, WORKSPACE_INVENTORY_REL), "the hub inventory").filter(
691
+ (id) => id !== "",
692
+ );
693
+ const batch = 20;
694
+ const entries: InventoryValidationEntry[] = [];
695
+ for (let index = 0; index < ids.length; index += batch) {
696
+ for (const id of ids.slice(index, index + batch)) {
697
+ const viewed = host.run("gh", ["repo", "view", id, "--json", "name"]);
698
+ if (viewed.status === 0) {
699
+ entries.push({ id, known: true });
700
+ } else if (viewed.status === 127 || (viewed.status === null && viewed.stderr.includes("ENOENT"))) {
701
+ entries.push({ id, known: null, note: "`gh` unavailable; not checked" });
702
+ } else {
703
+ entries.push({ id, known: false, note: "`gh repo view` failed; the repository may not exist" });
704
+ }
705
+ }
706
+ }
707
+ return { entries, skipped: false };
708
+ }
709
+
513
710
  function withHealth(
514
711
  host: WorkspaceHost,
515
712
  directory: string,
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  /** Consumer-owned workspace hub scaffolder: create, resume, or appoint a GitHub repository. */
2
2
  export {
3
3
  applyWorkspacePlan,
4
+ checkInventoryEntries,
4
5
  formatHubHealth,
6
+ hasAdvisorPin,
5
7
  inspectInventory,
6
8
  isHubDocument,
7
9
  observeWorkspace,
@@ -15,9 +17,14 @@ export {
15
17
  export type {
16
18
  CommandResult,
17
19
  CwdObservation,
20
+ DependencyBucket,
18
21
  HubDocument,
19
22
  HubHealthReport,
20
23
  InventoryObservation,
24
+ InventoryValidationEntry,
25
+ InventoryValidationReport,
26
+ PinFinding,
27
+ PinGrade,
21
28
  WorkspaceApplyResult,
22
29
  WorkspaceDecision,
23
30
  WorkspaceHost,
package/src/types.ts CHANGED
@@ -37,6 +37,34 @@ export interface InventoryObservation {
37
37
  readonly count: number;
38
38
  }
39
39
 
40
+ /** A package.json dependency bucket scanned for the advisor pin. */
41
+ export type DependencyBucket = "dependencies" | "devDependencies" | "optionalDependencies" | "peerDependencies";
42
+
43
+ /** Staleness verdict for one pinned advisor version against the live registry version. */
44
+ export type PinGrade = "stale" | "current" | "indeterminate";
45
+
46
+ /** One graded advisor pin in one dependency bucket. */
47
+ export interface PinFinding {
48
+ readonly bucket: DependencyBucket;
49
+ readonly pinned: string;
50
+ readonly grade: PinGrade;
51
+ readonly note?: string;
52
+ }
53
+
54
+ /** Verdict for one hub inventory repository id after a read-only existence check. */
55
+ export interface InventoryValidationEntry {
56
+ readonly id: string;
57
+ readonly known: boolean | null;
58
+ readonly note?: string;
59
+ }
60
+
61
+ /** Read-only validation outcome for hub inventory ids. Never mutates the inventory. */
62
+ export interface InventoryValidationReport {
63
+ readonly entries: readonly InventoryValidationEntry[];
64
+ readonly skipped: boolean;
65
+ readonly note?: string;
66
+ }
67
+
40
68
  /** Read-only pin and inventory report after adopt or resume. Never uninstalls. */
41
69
  export interface HubHealthReport {
42
70
  readonly marker: "present" | "missing";
@@ -44,10 +72,14 @@ export interface HubHealthReport {
44
72
  readonly advisorPin: {
45
73
  readonly dependencies?: string;
46
74
  readonly devDependencies?: string;
75
+ readonly optionalDependencies?: string;
76
+ readonly peerDependencies?: string;
47
77
  readonly live?: string;
48
78
  };
49
79
  readonly dualPin: boolean;
50
80
  readonly extraClossys: readonly string[];
81
+ readonly pinFindings: readonly PinFinding[];
82
+ readonly degraded: boolean;
51
83
  }
52
84
 
53
85
  export interface CwdObservation {
@@ -95,6 +127,8 @@ export interface WorkspacePlanAdopt {
95
127
  readonly advisorVersion: string;
96
128
  /** Absolute path of a populated inventory document to copy. Absent when cwd already has one. */
97
129
  readonly inventorySource?: string;
130
+ /** Merged repository ids (on-disk first, then new ids from --inventory) written when both sources are populated. */
131
+ readonly mergedInventoryIds?: readonly string[];
98
132
  }
99
133
 
100
134
  export type WorkspacePlan = WorkspacePlanCreate | WorkspacePlanResume | WorkspacePlanAdopt;