@mercury-fw/cli 0.41.0 → 0.43.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @mercury-fw/cli
2
2
 
3
+ ## 0.43.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 07eb858: ZITADEL as the user directory.
8
+
9
+ - `@mercury-fw/plugin-zitadel` exports `zitadelDirectory`. Someone calling with a ZITADEL token is the ZITADEL user the token names, with the role keys of their active role assignments on the project `ZITADEL_PROJECT_ID`. A user ZITADEL doesn't find, or one that isn't active, is unknown. It reads through the zitadel CLI's service user, from code.
10
+ - `mfw create --directory <id>` (`static` or `zitadel`) gives the new app a user directory, and the wizard asks for one.
11
+
12
+ ### Patch Changes
13
+
14
+ - @mercury-fw/core@0.43.0
15
+ - @mercury-fw/utils@0.43.0
16
+
17
+ ## 0.42.0
18
+
19
+ ### Patch Changes
20
+
21
+ - Updated dependencies [a7f2f13]
22
+ - @mercury-fw/core@0.42.0
23
+ - @mercury-fw/utils@0.42.0
24
+
3
25
  ## 0.41.0
4
26
 
5
27
  ### Minor Changes
package/README.md CHANGED
@@ -62,6 +62,7 @@ Then it runs `bun install` in the app and creates a git repository on `main` wit
62
62
  | `--channels <ids>` | Comma-separated: `google-chat`, `http`. |
63
63
  | `--plugins <ids>` | Comma-separated: `jira`, `bitbucket`, `atlassian-admin`, `zitadel`. |
64
64
  | `--auth <id>` | The HTTP channel's auth provider, `oidc` or `static`: required with `http`, refused without it, since the channel doesn't start without one. The wizard asks for it once `http` is chosen. |
65
+ | `--directory <id>` | The user directory, `static` or `zitadel`: who the people are and their roles. Without one, the person is whoever the channel says, with no roles. |
65
66
  | `--local-packages <folder>` | Installs the packages packed in `<folder>` (`bun pm pack`) instead of the registry's, as [`mfw local-packages`](#mfw-local-packages-folder--mfw-local-packages---off) does on an existing app: their versions come from the tarballs, so a package that isn't published yet works too, and no newer `mfw` is looked up on the registry. For trying unreleased packages in a new app; the test bed makes its apps this way. |
66
67
  | `--git-remote <url>` | The repository's `origin`, taken as typed. |
67
68
  | `--no-install` | Don't run `bun install`. |
@@ -16,6 +16,8 @@ export type CreateArgs = {
16
16
  plugins?: string[];
17
17
  /** The auth provider, which goes with the HTTP channel. */
18
18
  auth?: string;
19
+ /** The user directory. */
20
+ directory?: string;
19
21
  /** The repository's origin, taken as typed. */
20
22
  gitRemote?: string;
21
23
  /** A folder of packed tarballs the app installs instead of the registry's packages. */
@@ -34,6 +36,7 @@ export type CreateOptions = {
34
36
  channels?: string;
35
37
  plugins?: string;
36
38
  auth?: string;
39
+ directory?: string;
37
40
  gitRemote?: string;
38
41
  localPackages?: string;
39
42
  yes?: boolean;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The channels, auth providers and tool plugins `mfw create` can put in a new app. Written
2
+ * The channels, auth providers, tool plugins and user directories `mfw create` can put in a new app. Written
3
3
  * by hand for now: once the packages are published, each plugin will describe
4
4
  * itself (kind, export, env vars) and this list goes away. `catalog.test.ts`
5
5
  * checks every entry against the real package until then.
@@ -11,13 +11,14 @@ export type EnvVar = {
11
11
  comment: string;
12
12
  value?: string;
13
13
  };
14
- /** A channel, auth provider or tool plugin the app can include. `id` is the
15
- * plugin's own `name` (what the logs and `dependsOn` call it), `exportName` the
16
- * value the app's config imports from `package`. An auth provider goes with the
17
- * HTTP channel, which doesn't start without one. */
14
+ /** A channel, auth provider, tool plugin or user directory the app can include.
15
+ * `id` is the plugin's own `name` (what the logs and `dependsOn` call it; for a
16
+ * directory, the choice's name), `exportName` the value the app's config
17
+ * imports from `package`. An auth provider goes with the HTTP channel, which
18
+ * doesn't start without one; a directory goes with any channel. */
18
19
  export type CatalogEntry = {
19
20
  id: string;
20
- kind: "channel" | "auth" | "tool";
21
+ kind: "channel" | "auth" | "tool" | "directory";
21
22
  package: string;
22
23
  exportName: string;
23
24
  env: EnvVar[];
@@ -8,6 +8,8 @@ export type RenderInput = {
8
8
  plugins: string[];
9
9
  /** The HTTP channel's auth provider: required with it, refused without it. */
10
10
  auth?: string;
11
+ /** The user directory: who the people are and their roles. */
12
+ directory?: string;
11
13
  versions: Record<string, string>;
12
14
  };
13
15
  /** Builds every file of the new app. Throws on an invalid app name, an empty
@@ -17,10 +19,10 @@ export declare function renderApp(input: RenderInput): Map<string, string>;
17
19
  /** Why `name` can't be an app name, or undefined when it can. Shared with the
18
20
  * wizard, which checks the name as it's typed. */
19
21
  export declare function appNameError(name: string): string | undefined;
20
- /** Names the first channel, plugin or auth provider id the catalog doesn't
21
- * have, with the valid ones, or undefined when all are known. Shared with the
22
- * command, which checks the flags before asking anything. */
23
- export declare function selectionError(channels: string[], plugins: string[], auth?: string): string | undefined;
22
+ /** Names the first channel, plugin, auth provider or directory id the catalog
23
+ * doesn't have, with the valid ones, or undefined when all are known. Shared
24
+ * with the command, which checks the flags before asking anything. */
25
+ export declare function selectionError(channels: string[], plugins: string[], auth?: string, directory?: string): string | undefined;
24
26
  /** What's wrong with pairing the HTTP channel and the auth provider: it
25
27
  * doesn't start without one, and one without it has nothing to authenticate.
26
28
  * Undefined when they go together. Shared with the command, which checks it
@@ -9,6 +9,8 @@ export type Answers = {
9
9
  plugins: string[];
10
10
  /** The HTTP channel's auth provider, there exactly when that channel is. */
11
11
  auth?: string;
12
+ /** The user directory, when there's one. */
13
+ directory?: string;
12
14
  gitRemote?: string;
13
15
  };
14
16
  export declare const DEFAULT_ASSISTANT_NAME = "Mercury";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/cli",
3
- "version": "0.41.0",
3
+ "version": "0.43.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,20 +41,21 @@
41
41
  "comment:shape": "The Mercury CLI (`mfw`). `mfw create <dir>` writes a new Mercury app from the template. The framework packages move in lockstep with it, so a new app gets them at the CLI's own version; plugins and channels at the registry's latest. The plugin and channel packages are devDependencies only for the catalog test. `main(argv)` is exported for create-mercury-agent.",
42
42
  "dependencies": {
43
43
  "@clack/prompts": "^1.8.1",
44
- "@mercury-fw/core": "0.41.0",
45
- "@mercury-fw/utils": "0.41.0",
44
+ "@mercury-fw/core": "0.43.0",
45
+ "@mercury-fw/utils": "0.43.0",
46
46
  "commander": "^15.0.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@mercury-fw/channel-google-chat": "0.4.0",
50
- "@mercury-fw/channel-http": "0.5.0",
51
49
  "@mercury-fw/auth-oidc": "0.1.0",
52
- "@mercury-fw/auth-static": "0.1.0",
53
- "@mercury-fw/formatter": "0.41.0",
54
- "@mercury-fw/plugin-atlassian-admin": "0.3.0",
55
- "@mercury-fw/plugin-bitbucket": "0.3.0",
56
- "@mercury-fw/plugin-jira": "0.6.0",
57
- "@mercury-fw/plugin-zitadel": "0.1.0",
50
+ "@mercury-fw/auth-static": "0.2.0",
51
+ "@mercury-fw/channel-google-chat": "0.5.0",
52
+ "@mercury-fw/channel-http": "0.6.0",
53
+ "@mercury-fw/directory-static": "0.1.0",
54
+ "@mercury-fw/formatter": "0.43.0",
55
+ "@mercury-fw/plugin-atlassian-admin": "0.3.1",
56
+ "@mercury-fw/plugin-bitbucket": "0.3.1",
57
+ "@mercury-fw/plugin-jira": "0.6.1",
58
+ "@mercury-fw/plugin-zitadel": "0.2.0",
58
59
  "@mercury-fw/typescript-config": "*",
59
60
  "@types/bun": "^1.4.2",
60
61
  "typescript": "^6.0.3"
package/src/args.ts CHANGED
@@ -17,6 +17,8 @@ export type CreateArgs = {
17
17
  plugins?: string[];
18
18
  /** The auth provider, which goes with the HTTP channel. */
19
19
  auth?: string;
20
+ /** The user directory. */
21
+ directory?: string;
20
22
  /** The repository's origin, taken as typed. */
21
23
  gitRemote?: string;
22
24
  /** A folder of packed tarballs the app installs instead of the registry's packages. */
@@ -36,6 +38,7 @@ export type CreateOptions = {
36
38
  channels?: string;
37
39
  plugins?: string;
38
40
  auth?: string;
41
+ directory?: string;
39
42
  gitRemote?: string;
40
43
  localPackages?: string;
41
44
  yes?: boolean;
@@ -59,6 +62,7 @@ export function toCreateArgs(folder: string, opts: CreateOptions): CreateArgs {
59
62
  if (opts.channels !== undefined) args.channels = list(opts.channels);
60
63
  if (opts.plugins !== undefined) args.plugins = list(opts.plugins);
61
64
  if (opts.auth !== undefined && opts.auth.trim() !== "") args.auth = opts.auth.trim();
65
+ if (opts.directory !== undefined && opts.directory.trim() !== "") args.directory = opts.directory.trim();
62
66
  if (opts.localPackages !== undefined) args.localPackages = opts.localPackages;
63
67
  // Blank is none, as in the wizard.
64
68
  if (opts.gitRemote !== undefined && opts.gitRemote.trim() !== "") args.gitRemote = opts.gitRemote.trim();
package/src/catalog.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The channels, auth providers and tool plugins `mfw create` can put in a new app. Written
2
+ * The channels, auth providers, tool plugins and user directories `mfw create` can put in a new app. Written
3
3
  * by hand for now: once the packages are published, each plugin will describe
4
4
  * itself (kind, export, env vars) and this list goes away. `catalog.test.ts`
5
5
  * checks every entry against the real package until then.
@@ -9,13 +9,14 @@
9
9
  * example: `value` is the example value, empty when the deployer must fill it. */
10
10
  export type EnvVar = { name: string; comment: string; value?: string };
11
11
 
12
- /** A channel, auth provider or tool plugin the app can include. `id` is the
13
- * plugin's own `name` (what the logs and `dependsOn` call it), `exportName` the
14
- * value the app's config imports from `package`. An auth provider goes with the
15
- * HTTP channel, which doesn't start without one. */
12
+ /** A channel, auth provider, tool plugin or user directory the app can include.
13
+ * `id` is the plugin's own `name` (what the logs and `dependsOn` call it; for a
14
+ * directory, the choice's name), `exportName` the value the app's config
15
+ * imports from `package`. An auth provider goes with the HTTP channel, which
16
+ * doesn't start without one; a directory goes with any channel. */
16
17
  export type CatalogEntry = {
17
18
  id: string;
18
- kind: "channel" | "auth" | "tool";
19
+ kind: "channel" | "auth" | "tool" | "directory";
19
20
  package: string;
20
21
  exportName: string;
21
22
  env: EnvVar[];
@@ -122,6 +123,28 @@ export const CATALOG: CatalogEntry[] = [
122
123
  exportName: "zitadelPlugin",
123
124
  env: [],
124
125
  },
126
+ {
127
+ id: "static",
128
+ kind: "directory",
129
+ package: "@mercury-fw/directory-static",
130
+ exportName: "staticDirectory",
131
+ env: [
132
+ {
133
+ name: "DIRECTORY_STATIC_PEOPLE",
134
+ comment:
135
+ 'Required: the people, as JSON: [{"id": "alice", "roles": ["mercury.act-as-self"], "identities": ["static:alice"]}]',
136
+ },
137
+ ],
138
+ },
139
+ {
140
+ id: "zitadel",
141
+ kind: "directory",
142
+ package: "@mercury-fw/plugin-zitadel",
143
+ exportName: "zitadelDirectory",
144
+ env: [{ name: "ZITADEL_PROJECT_ID", comment: "Required: the ZITADEL project whose roles count" }],
145
+ // It calls the zitadel CLI, which the package downloads as it installs.
146
+ trusts: ["@mercury-fw/plugin-zitadel"],
147
+ },
125
148
  ];
126
149
 
127
150
  /** The catalog entry of `kind` with `id`, or undefined when there is none. */
package/src/main.ts CHANGED
@@ -29,6 +29,7 @@ function answersFromFlags(args: CreateArgs, defaultName: string): Answers {
29
29
  channels: args.channels ?? [],
30
30
  plugins: args.plugins ?? [],
31
31
  ...(args.auth !== undefined ? { auth: args.auth } : {}),
32
+ ...(args.directory !== undefined ? { directory: args.directory } : {}),
32
33
  ...(args.gitRemote !== undefined ? { gitRemote: args.gitRemote } : {}),
33
34
  };
34
35
  }
@@ -127,7 +128,7 @@ async function create(args: CreateArgs, rawArgs: string[], relaunch: Relaunch, g
127
128
  const pairedByFlags = args.yes || (args.channels !== undefined && args.auth !== undefined);
128
129
  const early =
129
130
  targetError(dir) ??
130
- selectionError(args.channels ?? [], args.plugins ?? [], args.auth) ??
131
+ selectionError(args.channels ?? [], args.plugins ?? [], args.auth, args.directory) ??
131
132
  (pairedByFlags ? pairingError(args.channels ?? [], args.auth) : undefined) ??
132
133
  remoteError(args);
133
134
  if (early !== undefined) {
@@ -138,9 +139,17 @@ async function create(args: CreateArgs, rawArgs: string[], relaunch: Relaunch, g
138
139
  if (answers === undefined) {
139
140
  return 1;
140
141
  }
141
- const chosen = CATALOG.filter((e) =>
142
- e.kind === "auth" ? e.id === answers.auth : (e.kind === "channel" ? answers.channels : answers.plugins).includes(e.id),
143
- ).map((e) => e.package);
142
+ const chosen = [
143
+ ...new Set(
144
+ CATALOG.filter((e) =>
145
+ e.kind === "auth"
146
+ ? e.id === answers.auth
147
+ : e.kind === "directory"
148
+ ? e.id === answers.directory
149
+ : (e.kind === "channel" ? answers.channels : answers.plugins).includes(e.id),
150
+ ).map((e) => e.package),
151
+ ),
152
+ ];
144
153
  const versions = await appVersions(chosen, {
145
154
  registry: registryFrom(process.env.MFW_REGISTRY),
146
155
  ...(packs !== undefined ? { local: Object.fromEntries(packs.map((p) => [p.name, p.version])) } : {}),
@@ -162,7 +171,7 @@ async function create(args: CreateArgs, rawArgs: string[], relaunch: Relaunch, g
162
171
  ...(answers.gitRemote !== undefined ? { remote: answers.gitRemote } : {}),
163
172
  commitMessage: [
164
173
  `Scaffold with mfw create ${cliVersion()}`,
165
- `Channels: ${none(answers.channels)}\nPlugins: ${none(answers.plugins)}${answers.auth !== undefined ? `\nAuth: ${answers.auth}` : ""}`,
174
+ `Channels: ${none(answers.channels)}\nPlugins: ${none(answers.plugins)}${answers.auth !== undefined ? `\nAuth: ${answers.auth}` : ""}${answers.directory !== undefined ? `\nDirectory: ${answers.directory}` : ""}`,
166
175
  ],
167
176
  },
168
177
  run,
package/src/program.ts CHANGED
@@ -67,6 +67,10 @@ function buildProgram(handlers: ProgramHandlers, result: { code: number }): Comm
67
67
  "--auth <id>",
68
68
  `the HTTP channel's auth provider, required with it: ${CATALOG.filter((e) => e.kind === "auth").map((e) => e.id).join(", ")}`,
69
69
  )
70
+ .option(
71
+ "--directory <id>",
72
+ `the user directory, who the people are and their roles: ${CATALOG.filter((e) => e.kind === "directory").map((e) => e.id).join(", ")} (default: none)`,
73
+ )
70
74
  .option("--git-remote <url>", "the repository's origin, taken as typed (default: none)")
71
75
  .option(
72
76
  "--local-packages <folder>",
package/src/render.ts CHANGED
@@ -28,6 +28,8 @@ export type RenderInput = {
28
28
  plugins: string[];
29
29
  /** The HTTP channel's auth provider: required with it, refused without it. */
30
30
  auth?: string;
31
+ /** The user directory: who the people are and their roles. */
32
+ directory?: string;
31
33
  versions: Record<string, string>;
32
34
  };
33
35
 
@@ -66,18 +68,19 @@ export function renderApp(input: RenderInput): Map<string, string> {
66
68
  const channels = selected("channel", input.channels);
67
69
  const tools = selected("tool", input.plugins);
68
70
  const auth = selected("auth", input.auth === undefined ? [] : [input.auth])[0];
71
+ const directory = selected("directory", input.directory === undefined ? [] : [input.directory])[0];
69
72
  const assistantName = input.assistantName.trim();
70
73
 
71
74
  return new Map([
72
75
  [".dockerignore", dockerignore],
73
- [".env.example", renderEnv(channels, auth, tools)],
76
+ [".env.example", renderEnv(channels, auth, tools, directory)],
74
77
  [".gitignore", gitignore],
75
78
  ["Dockerfile", dockerfile],
76
- ["README.md", renderReadme(input.name, channels, auth, tools)],
79
+ ["README.md", renderReadme(input.name, channels, auth, tools, directory)],
77
80
  ["docker-compose.yml", renderCompose(input.name, channels.some((c) => c.id === "http"))],
78
81
  ["markdown.d.ts", markdownDts],
79
- ["mercury.config.ts", renderConfig(channels, auth, tools)],
80
- ["package.json", renderPackageJson(input.name, channels, auth, tools, input.versions)],
82
+ ["mercury.config.ts", renderConfig(channels, auth, tools, directory)],
83
+ ["package.json", renderPackageJson(input.name, channels, auth, tools, input.versions, directory)],
81
84
  ["persona/identity.md", `You are ${assistantName}, ${input.role.trim().replace(/\.+$/, "")}.\n`],
82
85
  // A replacer function, not a string: in a replacement string "$&" and the
83
86
  // like are patterns, and the name must land verbatim.
@@ -112,7 +115,8 @@ function validate(input: RenderInput): void {
112
115
  if (/[\r\n]/.test(input.assistantName) || /[\r\n]/.test(input.role)) {
113
116
  throw new Error("The assistant name and role must each be one line");
114
117
  }
115
- const selection = selectionError(input.channels, input.plugins, input.auth) ?? pairingError(input.channels, input.auth);
118
+ const selection =
119
+ selectionError(input.channels, input.plugins, input.auth, input.directory) ?? pairingError(input.channels, input.auth);
116
120
  if (selection !== undefined) {
117
121
  throw new Error(selection);
118
122
  }
@@ -121,14 +125,15 @@ function validate(input: RenderInput): void {
121
125
  /** The ids of the catalog entries of `kind`. */
122
126
  const idsOf = (kind: CatalogEntry["kind"]): string[] => CATALOG.filter((e) => e.kind === kind).map((e) => e.id);
123
127
 
124
- /** Names the first channel, plugin or auth provider id the catalog doesn't
125
- * have, with the valid ones, or undefined when all are known. Shared with the
126
- * command, which checks the flags before asking anything. */
127
- export function selectionError(channels: string[], plugins: string[], auth?: string): string | undefined {
128
+ /** Names the first channel, plugin, auth provider or directory id the catalog
129
+ * doesn't have, with the valid ones, or undefined when all are known. Shared
130
+ * with the command, which checks the flags before asking anything. */
131
+ export function selectionError(channels: string[], plugins: string[], auth?: string, directory?: string): string | undefined {
128
132
  for (const [kind, ids, label] of [
129
133
  ["channel", channels, "channel"],
130
134
  ["tool", plugins, "plugin"],
131
135
  ["auth", auth === undefined ? [] : [auth], "auth provider"],
136
+ ["directory", directory === undefined ? [] : [directory], "directory"],
132
137
  ] as const) {
133
138
  const valid = idsOf(kind);
134
139
  const unknown = ids.find((id) => !valid.includes(id));
@@ -158,7 +163,12 @@ function selected(kind: CatalogEntry["kind"], ids: string[]): CatalogEntry[] {
158
163
  /** `mercury.config.ts`: imports, the formatter helpers of the plugins that have
159
164
  * any, then the config with each plugin (wrapped in the formatter when it has
160
165
  * starting rules) and channel. */
161
- function renderConfig(channels: CatalogEntry[], auth: CatalogEntry | undefined, tools: CatalogEntry[]): string {
166
+ function renderConfig(
167
+ channels: CatalogEntry[],
168
+ auth: CatalogEntry | undefined,
169
+ tools: CatalogEntry[],
170
+ directory: CatalogEntry | undefined,
171
+ ): string {
162
172
  const withRules = tools.filter((t) => t.formatter);
163
173
  const imports = ['import { defineMercuryConfig } from "@mercury-fw/core";'];
164
174
  if (withRules.length > 0) {
@@ -168,7 +178,7 @@ function renderConfig(channels: CatalogEntry[], auth: CatalogEntry | undefined,
168
178
  const names = t.formatter ? `${t.exportName}, type ${t.formatter.displaysType}` : t.exportName;
169
179
  imports.push(`import { ${names} } from "${t.package}";`);
170
180
  }
171
- for (const c of [...channels, ...(auth ? [auth] : [])]) {
181
+ for (const c of [...channels, ...(auth ? [auth] : []), ...(directory ? [directory] : [])]) {
172
182
  imports.push(`import { ${c.exportName} } from "${c.package}";`);
173
183
  }
174
184
  imports.push('import identity from "./persona/identity.md" with { type: "text" };');
@@ -191,6 +201,7 @@ function renderConfig(channels: CatalogEntry[], auth: CatalogEntry | undefined,
191
201
  plugins,
192
202
  channelList,
193
203
  ...(auth ? [` auth: ${auth.exportName},`] : []),
204
+ ...(directory ? [` directory: ${directory.exportName},`] : []),
194
205
  "});",
195
206
  "",
196
207
  ].join("\n");
@@ -213,15 +224,17 @@ function renderPluginEntry(t: CatalogEntry): string {
213
224
 
214
225
  /** `package.json`: the core, the formatter when a plugin is wrapped in it, the
215
226
  * chosen packages, and every tool plugin trusted to run its postinstall (it
216
- * downloads the plugin's CLI). */
227
+ * downloads the plugin's CLI), plus what a chosen entry says it needs trusted. */
217
228
  function renderPackageJson(
218
229
  name: string,
219
230
  channels: CatalogEntry[],
220
231
  auth: CatalogEntry | undefined,
221
232
  tools: CatalogEntry[],
222
233
  versions: Record<string, string>,
234
+ directory: CatalogEntry | undefined,
223
235
  ): string {
224
- const packages = ["@mercury-fw/core", ...[...channels, ...(auth ? [auth] : []), ...tools].map((e) => e.package)];
236
+ const chosen = [...channels, ...(auth ? [auth] : []), ...tools, ...(directory ? [directory] : [])];
237
+ const packages = [...new Set(["@mercury-fw/core", ...chosen.map((e) => e.package)])];
225
238
  if (tools.some((t) => t.formatter)) {
226
239
  packages.push("@mercury-fw/formatter");
227
240
  }
@@ -248,7 +261,7 @@ function renderPackageJson(
248
261
  // hands the app's commands over to it.
249
262
  devDependencies: { "@mercury-fw/cli": `^${cli}`, "@types/bun": "^1.4.2", typescript: "^6.0.3" },
250
263
  };
251
- const trusted = [...new Set([...tools.map((t) => t.package), ...[...channels, ...tools].flatMap((e) => e.trusts ?? [])])];
264
+ const trusted = [...new Set([...tools.map((t) => t.package), ...chosen.flatMap((e) => e.trusts ?? [])])];
252
265
  if (trusted.length > 0) {
253
266
  manifest.trustedDependencies = trusted.sort();
254
267
  }
@@ -257,12 +270,18 @@ function renderPackageJson(
257
270
 
258
271
  /** The env example: the core's variables, then a section per chosen entry
259
272
  * that reads any variable. */
260
- function renderEnv(channels: CatalogEntry[], auth: CatalogEntry | undefined, tools: CatalogEntry[]): string {
273
+ function renderEnv(
274
+ channels: CatalogEntry[],
275
+ auth: CatalogEntry | undefined,
276
+ tools: CatalogEntry[],
277
+ directory: CatalogEntry | undefined,
278
+ ): string {
261
279
  const block = (vars: EnvVar[]) => vars.map((v) => `# ${v.comment}\n${v.name}=${v.value ?? ""}`).join("\n");
262
280
  const sections = [block(CORE_ENV)];
263
- for (const entry of [...channels, ...(auth ? [auth] : []), ...tools]) {
281
+ for (const entry of [...channels, ...(auth ? [auth] : []), ...tools, ...(directory ? [directory] : [])]) {
264
282
  if (entry.env.length > 0) {
265
- sections.push(`# --- ${entry.id}\n${block(entry.env)}`);
283
+ // A directory can share its id with a tool plugin of the same package.
284
+ sections.push(`# --- ${entry.id}${entry.kind === "directory" ? " directory" : ""}\n${block(entry.env)}`);
266
285
  }
267
286
  }
268
287
  return `${sections.join("\n\n")}\n`;
@@ -317,7 +336,13 @@ function renderCompose(name: string, hasHttp: boolean): string {
317
336
  }
318
337
 
319
338
  /** The app's README: what it was scaffolded with and how to run it. */
320
- function renderReadme(name: string, channels: CatalogEntry[], auth: CatalogEntry | undefined, tools: CatalogEntry[]): string {
339
+ function renderReadme(
340
+ name: string,
341
+ channels: CatalogEntry[],
342
+ auth: CatalogEntry | undefined,
343
+ tools: CatalogEntry[],
344
+ directory: CatalogEntry | undefined,
345
+ ): string {
321
346
  const list = (entries: CatalogEntry[]) => (entries.length > 0 ? entries.map((e) => e.id).join(", ") : "none");
322
347
  return `# ${name}
323
348
 
@@ -325,7 +350,7 @@ A Mercury app, scaffolded by \`mfw create\`.
325
350
 
326
351
  - Channels: ${list(channels)}
327
352
  ${auth ? `- Auth: ${auth.id}\n` : ""}- Tool plugins: ${list(tools)}
328
-
353
+ ${directory ? `- Directory: ${directory.id}\n` : ""}
329
354
  ## Layout
330
355
 
331
356
  - \`mercury.config.ts\`: what the app is made of (tool plugins, channels, how their lists read) and the assistant's persona.
package/src/wizard.ts CHANGED
@@ -19,6 +19,8 @@ export type Answers = {
19
19
  plugins: string[];
20
20
  /** The HTTP channel's auth provider, there exactly when that channel is. */
21
21
  auth?: string;
22
+ /** The user directory, when there's one. */
23
+ directory?: string;
22
24
  gitRemote?: string;
23
25
  };
24
26
 
@@ -87,6 +89,17 @@ export async function askAnswers(args: CreateArgs, dir: string): Promise<Answers
87
89
  }
88
90
  const plugins = await pick("tool", "Tool plugins (space to select)", args.plugins ?? []);
89
91
  if (plugins === undefined) return cancelled();
92
+ const NO_DIRECTORY = "";
93
+ const pickedDirectory = await p.select({
94
+ message: "User directory (who the people are and their roles; none: whoever the channel says, with no roles)",
95
+ options: [
96
+ { value: NO_DIRECTORY, label: "none" },
97
+ ...CATALOG.filter((e) => e.kind === "directory").map((e) => ({ value: e.id, label: e.id, hint: e.package })),
98
+ ],
99
+ initialValue: args.directory ?? NO_DIRECTORY,
100
+ });
101
+ if (p.isCancel(pickedDirectory)) return cancelled();
102
+ const directory = pickedDirectory === NO_DIRECTORY ? undefined : pickedDirectory;
90
103
  // Asked only when there will be a repository and the flag didn't say.
91
104
  let gitRemote = args.gitRemote;
92
105
  if (args.git && gitRemote === undefined) {
@@ -105,6 +118,7 @@ export async function askAnswers(args: CreateArgs, dir: string): Promise<Answers
105
118
  `Channels: ${channels.length > 0 ? channels.join(", ") : "none"}`,
106
119
  ...(auth !== undefined ? [`Auth: ${auth}`] : []),
107
120
  `Tool plugins: ${plugins.length > 0 ? plugins.join(", ") : "none"}`,
121
+ `Directory: ${directory ?? "none"}`,
108
122
  ...(args.git ? [`Origin: ${gitRemote ?? "none"}`] : []),
109
123
  ].join("\n"),
110
124
  "Summary",
@@ -118,6 +132,7 @@ export async function askAnswers(args: CreateArgs, dir: string): Promise<Answers
118
132
  channels,
119
133
  plugins,
120
134
  ...(auth !== undefined ? { auth } : {}),
135
+ ...(directory !== undefined ? { directory } : {}),
121
136
  ...(gitRemote !== undefined ? { gitRemote } : {}),
122
137
  };
123
138
  }