@pikku/inspector 0.12.111 → 0.12.113

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.
@@ -0,0 +1,10 @@
1
+ import * as ts from 'typescript';
2
+ import type { InspectorLogger, InspectorState } from '../types.js';
3
+ /**
4
+ * Detect `defineContributors({ [name]: { dev?, deploy? } })` and record, per
5
+ * name, the module and export of each variant. A name defined twice, or a
6
+ * variant a host could not import by itself, is a critical error: a contributor
7
+ * that silently drops out would leave the host without the service it opted in
8
+ * to.
9
+ */
10
+ export declare function addDefineContributors(logger: InspectorLogger, node: ts.Node, checker: ts.TypeChecker, state: InspectorState): void;
@@ -0,0 +1,98 @@
1
+ import * as ts from 'typescript';
2
+ import { ErrorCode } from '../error-codes.js';
3
+ import { callsImportedDefiner, exportedName } from './add-analytics.js';
4
+ const DEFINE_CONTRIBUTORS = 'defineContributors';
5
+ const VARIANTS = ['dev', 'deploy'];
6
+ const propertyKey = (name) => ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined;
7
+ /**
8
+ * Where an exported contributor const lives and what it is exported as. A
9
+ * host imports exactly this, so a variant that is inline, not exported, or
10
+ * built by a call has no answer and is reported instead.
11
+ */
12
+ const resolveVariant = (value, checker) => {
13
+ if (!ts.isIdentifier(value)) {
14
+ return `must be a reference to an exported const so a host can import just that variant, got: ${value.getText()}`;
15
+ }
16
+ let symbol = checker.getSymbolAtLocation(value);
17
+ if (symbol && symbol.flags & ts.SymbolFlags.Alias) {
18
+ symbol = checker.getAliasedSymbol(symbol);
19
+ }
20
+ const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0];
21
+ if (!declaration ||
22
+ !ts.isVariableDeclaration(declaration) ||
23
+ !ts.isIdentifier(declaration.name)) {
24
+ return `'${value.text}' does not resolve to a const declaration`;
25
+ }
26
+ const exported = exportedName(declaration, declaration.name, checker);
27
+ if (!exported) {
28
+ return `'${value.text}' is not exported by ${declaration.getSourceFile().fileName}`;
29
+ }
30
+ return { module: declaration.getSourceFile().fileName, export: exported };
31
+ };
32
+ /**
33
+ * Detect `defineContributors({ [name]: { dev?, deploy? } })` and record, per
34
+ * name, the module and export of each variant. A name defined twice, or a
35
+ * variant a host could not import by itself, is a critical error: a contributor
36
+ * that silently drops out would leave the host without the service it opted in
37
+ * to.
38
+ */
39
+ export function addDefineContributors(logger, node, checker, state) {
40
+ if (!ts.isCallExpression(node))
41
+ return;
42
+ if (!callsImportedDefiner(node.expression, checker, DEFINE_CONTRIBUTORS))
43
+ return;
44
+ const fail = (code, message) => logger.critical(code, message);
45
+ const notStatic = (message) => fail(ErrorCode.CONTRIBUTORS_NOT_STATIC, message);
46
+ const [argument] = node.arguments;
47
+ if (!argument || !ts.isObjectLiteralExpression(argument)) {
48
+ notStatic(`defineContributors must be called with an object literal keyed by contributor name${argument ? `, got: ${argument.getText()}` : ''}`);
49
+ return;
50
+ }
51
+ for (const property of argument.properties) {
52
+ const name = ts.isPropertyAssignment(property)
53
+ ? propertyKey(property.name)
54
+ : undefined;
55
+ if (!name || !ts.isPropertyAssignment(property)) {
56
+ notStatic(`defineContributors takes { name: { dev?, deploy? } } entries, got: ${property.getText()}`);
57
+ continue;
58
+ }
59
+ if (!ts.isObjectLiteralExpression(property.initializer)) {
60
+ notStatic(`Contributor '${name}' must be an object literal with dev and/or deploy, got: ${property.initializer.getText()}`);
61
+ continue;
62
+ }
63
+ const variants = {};
64
+ let valid = true;
65
+ for (const variant of property.initializer.properties) {
66
+ const key = ts.isPropertyAssignment(variant)
67
+ ? propertyKey(variant.name)
68
+ : undefined;
69
+ if (!key ||
70
+ !ts.isPropertyAssignment(variant) ||
71
+ !VARIANTS.includes(key)) {
72
+ notStatic(`Contributor '${name}' takes only dev and deploy, got: ${variant.getText()}`);
73
+ valid = false;
74
+ continue;
75
+ }
76
+ const ref = resolveVariant(variant.initializer, checker);
77
+ if (typeof ref === 'string') {
78
+ notStatic(`Contributor '${name}' ${key} ${ref}`);
79
+ valid = false;
80
+ continue;
81
+ }
82
+ variants[key] = ref;
83
+ }
84
+ if (!valid)
85
+ continue;
86
+ if (!variants.dev && !variants.deploy) {
87
+ notStatic(`Contributor '${name}' declares neither dev nor deploy`);
88
+ continue;
89
+ }
90
+ const manifest = (state.contributorsManifest ??= {});
91
+ if (manifest[name]) {
92
+ fail(ErrorCode.CONTRIBUTOR_NAME_DUPLICATED, `Contributor '${name}' is defined twice. A contributor name belongs to one defineContributors entry.`);
93
+ continue;
94
+ }
95
+ logger.debug(`• Found ${DEFINE_CONTRIBUTORS}: ${name}`);
96
+ manifest[name] = variants;
97
+ }
98
+ }
@@ -1,14 +1,14 @@
1
1
  import * as ts from 'typescript';
2
2
  import type { InspectorState, InspectorLogger } from '../types.js';
3
3
  /**
4
- * Detect `pikkuStudio({ title, screens })` and record it as the package's
4
+ * Detect `definePlugin({ title, screens })` and record it as the package's
5
5
  * `studio` metadata. Every field must be a literal: one that is not is
6
6
  * reported and its screen left out, since a screen the host cannot read would
7
7
  * otherwise vanish with no sign.
8
8
  */
9
9
  export declare function addPikkuStudio(node: ts.Node, state: InspectorState, logger: InspectorLogger): void;
10
10
  /**
11
- * The checks on `pikkuStudio` that need the whole package: every title is a
11
+ * The checks on `definePlugin` that need the whole package: every title is a
12
12
  * key in the addon's `messages/en.json` (a key missing from another locale is
13
13
  * a warning), and every nav badge names one of the addon's functions.
14
14
  */
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { existsSync, readFileSync, readdirSync } from 'fs';
2
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
3
3
  import { dirname, join } from 'path';
4
4
  import { ErrorCode } from '../error-codes.js';
5
5
  const SCOPES = ['studio', 'team', 'project'];
@@ -97,7 +97,7 @@ const readNav = (node, fail) => {
97
97
  };
98
98
  const readScreen = (element, fail) => {
99
99
  if (!ts.isObjectLiteralExpression(element)) {
100
- fail(`A pikkuStudio screen must be an object literal, got: ${element.getText()}`);
100
+ fail(`A definePlugin screen must be an object literal, got: ${element.getText()}`);
101
101
  return undefined;
102
102
  }
103
103
  let path;
@@ -110,6 +110,7 @@ const readScreen = (element, fail) => {
110
110
  let scopes;
111
111
  let component;
112
112
  let app;
113
+ let agent;
113
114
  let ok = true;
114
115
  for (const field of element.properties) {
115
116
  const key = propName(field);
@@ -136,6 +137,8 @@ const readScreen = (element, fail) => {
136
137
  read = replaces = booleanLiteral(value);
137
138
  else if (key === 'needsApp')
138
139
  read = needsApp = booleanLiteral(value);
140
+ else if (key === 'agent')
141
+ read = agent = ts.isStringLiteral(value) ? value.text : undefined;
139
142
  else if (key === 'layout')
140
143
  read = layout = oneOf(value, ['bare']);
141
144
  else if (key === 'scopes') {
@@ -166,7 +169,7 @@ const readScreen = (element, fail) => {
166
169
  if (!ok)
167
170
  return undefined;
168
171
  if (!path || !title || !scope || (!component && !app)) {
169
- fail(`A pikkuStudio screen needs a literal path, title and scope, and either a lazy component or an app: ${element.getText()}`);
172
+ fail(`A definePlugin screen needs a literal path, title and scope, and either a lazy component or an app: ${element.getText()}`);
170
173
  return undefined;
171
174
  }
172
175
  if (component && app) {
@@ -182,11 +185,12 @@ const readScreen = (element, fail) => {
182
185
  ...(needsApp !== undefined ? { needsApp } : {}),
183
186
  ...(layout ? { layout } : {}),
184
187
  ...(scopes ? { scopes } : {}),
188
+ ...(agent !== undefined ? { agent } : {}),
185
189
  ...(component ? { component } : { app: app }),
186
190
  };
187
191
  };
188
192
  /**
189
- * Detect `pikkuStudio({ title, screens })` and record it as the package's
193
+ * Detect `definePlugin({ title, screens })` and record it as the package's
190
194
  * `studio` metadata. Every field must be a literal: one that is not is
191
195
  * reported and its screen left out, since a screen the host cannot read would
192
196
  * otherwise vanish with no sign.
@@ -195,7 +199,8 @@ export function addPikkuStudio(node, state, logger) {
195
199
  if (!ts.isCallExpression(node))
196
200
  return;
197
201
  const { expression, arguments: args } = node;
198
- if (!ts.isIdentifier(expression) || expression.text !== 'pikkuStudio')
202
+ if (!ts.isIdentifier(expression) ||
203
+ (expression.text !== 'definePlugin' && expression.text !== 'pikkuStudio'))
199
204
  return;
200
205
  const [firstArg] = args;
201
206
  if (!firstArg || !ts.isObjectLiteralExpression(firstArg))
@@ -206,7 +211,7 @@ export function addPikkuStudio(node, state, logger) {
206
211
  for (const prop of firstArg.properties) {
207
212
  const key = propName(prop);
208
213
  if (!key || !ts.isPropertyAssignment(prop)) {
209
- fail(`A pikkuStudio field must be a literal, got: ${prop.getText()}`);
214
+ fail(`A definePlugin field must be a literal, got: ${prop.getText()}`);
210
215
  continue;
211
216
  }
212
217
  if (key === 'title') {
@@ -214,12 +219,12 @@ export function addPikkuStudio(node, state, logger) {
214
219
  title = prop.initializer.text;
215
220
  }
216
221
  else {
217
- fail(`pikkuStudio's title must be a literal, got: ${prop.initializer.getText()}`);
222
+ fail(`definePlugin's title must be a literal, got: ${prop.initializer.getText()}`);
218
223
  }
219
224
  }
220
225
  else if (key === 'screens') {
221
226
  if (!ts.isArrayLiteralExpression(prop.initializer)) {
222
- fail(`pikkuStudio's screens must be an array literal, got: ${prop.initializer.getText()}`);
227
+ fail(`definePlugin's screens must be an array literal, got: ${prop.initializer.getText()}`);
223
228
  continue;
224
229
  }
225
230
  for (const element of prop.initializer.elements) {
@@ -230,17 +235,17 @@ export function addPikkuStudio(node, state, logger) {
230
235
  }
231
236
  }
232
237
  if (!title) {
233
- fail(`pikkuStudio needs a literal title`);
238
+ fail(`definePlugin needs a literal title`);
234
239
  return;
235
240
  }
236
241
  const seen = new Set();
237
242
  for (const screen of screens) {
238
243
  if (seen.has(screen.path)) {
239
- logger.critical(ErrorCode.STUDIO_SCREEN_PATH_DUPLICATE, `pikkuStudio declares two screens at '${screen.path}'; each screen needs its own path`);
244
+ logger.critical(ErrorCode.STUDIO_SCREEN_PATH_DUPLICATE, `definePlugin declares two screens at '${screen.path}'; each screen needs its own path`);
240
245
  }
241
246
  seen.add(screen.path);
242
247
  }
243
- logger.debug(`• Found pikkuStudio: ${title} (${screens.length} screens)`);
248
+ logger.debug(`• Found definePlugin: ${title} (${screens.length} screens)`);
244
249
  state.studio = {
245
250
  title,
246
251
  file: node.getSourceFile().fileName,
@@ -259,6 +264,34 @@ const packageRoot = (file) => {
259
264
  dir = parent;
260
265
  }
261
266
  };
267
+ const AGENT_NAME = /^[a-z][a-z0-9-]*$/;
268
+ const STUDIO_AGENT = /^studio:[a-z][a-z0-9-]*$/;
269
+ /** The text of a file that exists and is not blank, else undefined. */
270
+ const readNonEmpty = (file) => {
271
+ try {
272
+ if (!statSync(file).isFile())
273
+ return undefined;
274
+ const text = readFileSync(file, 'utf-8');
275
+ return text.trim() === '' ? undefined : text;
276
+ }
277
+ catch {
278
+ return undefined;
279
+ }
280
+ };
281
+ /** The `key: value` lines of a leading `---` block; undefined when there is none. */
282
+ const readFrontmatter = (text) => {
283
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
284
+ if (!match)
285
+ return undefined;
286
+ const fields = {};
287
+ for (const line of match[1].split(/\r?\n/)) {
288
+ const pair = /^([A-Za-z][\w-]*):\s*(.*?)\s*$/.exec(line);
289
+ if (!pair)
290
+ continue;
291
+ fields[pair[1]] = pair[2].replace(/^(['"])(.*)\1$/, '$2');
292
+ }
293
+ return fields;
294
+ };
262
295
  const readCatalog = (file) => {
263
296
  try {
264
297
  return JSON.parse(readFileSync(file, 'utf-8'));
@@ -268,7 +301,7 @@ const readCatalog = (file) => {
268
301
  }
269
302
  };
270
303
  /**
271
- * The checks on `pikkuStudio` that need the whole package: every title is a
304
+ * The checks on `definePlugin` that need the whole package: every title is a
272
305
  * key in the addon's `messages/en.json` (a key missing from another locale is
273
306
  * a warning), and every nav badge names one of the addon's functions.
274
307
  */
@@ -282,7 +315,7 @@ export function validatePikkuStudio(logger, state) {
282
315
  const en = messagesDir ? readCatalog(join(messagesDir, 'en.json')) : undefined;
283
316
  for (const key of new Set(keys)) {
284
317
  if (!en || !Object.hasOwn(en, key)) {
285
- logger.critical(ErrorCode.STUDIO_MESSAGE_KEY_MISSING, `pikkuStudio uses the message key '${key}', which is missing from the addon's messages/en.json`);
318
+ logger.critical(ErrorCode.STUDIO_MESSAGE_KEY_MISSING, `definePlugin uses the message key '${key}', which is missing from the addon's messages/en.json`);
286
319
  }
287
320
  }
288
321
  if (messagesDir && existsSync(messagesDir)) {
@@ -293,12 +326,52 @@ export function validatePikkuStudio(logger, state) {
293
326
  const catalog = readCatalog(join(messagesDir, name)) ?? {};
294
327
  for (const key of new Set(keys)) {
295
328
  if (!Object.hasOwn(catalog, key)) {
296
- logger.warn(`pikkuStudio uses the message key '${key}', which is missing from the addon's messages/${name}`);
329
+ logger.warn(`definePlugin uses the message key '${key}', which is missing from the addon's messages/${name}`);
297
330
  }
298
331
  }
299
332
  }
300
333
  }
301
334
  for (const screen of studio.screens) {
335
+ const agent = screen.agent;
336
+ if (agent !== undefined) {
337
+ const invalid = (reason) => logger.critical(ErrorCode.STUDIO_SCREEN_AGENT_INVALID, `Screen '${screen.path}' has the agent '${agent}', ${reason}`);
338
+ if (agent.startsWith('studio:')) {
339
+ // An agent Studio ships: pikku cannot see it, so only the format is checked.
340
+ if (!STUDIO_AGENT.test(agent)) {
341
+ invalid("which is not a valid agent: use a name for an agent the addon ships (lowercase letters, digits and hyphens, starting with a letter) or 'studio:<name>' for one Studio ships");
342
+ }
343
+ }
344
+ else if (!AGENT_NAME.test(agent)) {
345
+ invalid("which is not a valid agent: use a name for an agent the addon ships (lowercase letters, digits and hyphens, starting with a letter) or 'studio:<name>' for one Studio ships");
346
+ }
347
+ else {
348
+ const text = root
349
+ ? readNonEmpty(join(root, 'agents', `${agent}.md`))
350
+ : undefined;
351
+ const fields = text === undefined ? undefined : readFrontmatter(text);
352
+ if (text === undefined) {
353
+ invalid(`but the addon has no non-empty agents/${agent}.md`);
354
+ }
355
+ else if (!fields) {
356
+ invalid(`whose agents/${agent}.md has no frontmatter; it needs a title and a description`);
357
+ }
358
+ else {
359
+ for (const field of ['title', 'description']) {
360
+ const key = fields[field];
361
+ if (!key) {
362
+ invalid(`whose agents/${agent}.md frontmatter has no ${field}; it must be a message key`);
363
+ }
364
+ else if (!en || !Object.hasOwn(en, key)) {
365
+ logger.critical(ErrorCode.STUDIO_MESSAGE_KEY_MISSING, `definePlugin uses the message key '${key}', which is missing from the addon's messages/en.json`);
366
+ }
367
+ else if (field === 'title')
368
+ screen.agentTitle = key;
369
+ else
370
+ screen.agentDescription = key;
371
+ }
372
+ }
373
+ }
374
+ }
302
375
  const badge = screen.nav?.badge;
303
376
  if (badge !== undefined && !state.functions.meta[badge]) {
304
377
  logger.critical(ErrorCode.STUDIO_BADGE_FUNCTION_NOT_FOUND, `Screen '${screen.path}' has the nav badge '${badge}', which names no function in this addon`);
@@ -87,8 +87,7 @@ export function addWireAddon(node, state, logger) {
87
87
  if (!ts.isCallExpression(node))
88
88
  return;
89
89
  const { expression, arguments: args } = node;
90
- if (!ts.isIdentifier(expression) ||
91
- expression.text !== 'wireAddon')
90
+ if (!ts.isIdentifier(expression) || expression.text !== 'wireAddon')
92
91
  return;
93
92
  const [firstArg] = args;
94
93
  if (!firstArg || !ts.isObjectLiteralExpression(firstArg))
@@ -114,6 +113,7 @@ export function addWireAddon(node, state, logger) {
114
113
  let contentBucket;
115
114
  let contentGrants;
116
115
  let globalContent;
116
+ let contributors;
117
117
  for (const prop of firstArg.properties) {
118
118
  if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
119
119
  continue;
@@ -217,6 +217,14 @@ export function addWireAddon(node, state, logger) {
217
217
  ts.isObjectLiteralExpression(prop.initializer)) {
218
218
  contentGrants = parseStringRecord(prop.initializer);
219
219
  }
220
+ else if (key === 'contributors') {
221
+ contributors = parseStringArray(prop.initializer);
222
+ // An unreadable list read as "none" would silently drop services the
223
+ // project asked for.
224
+ if (contributors === undefined) {
225
+ logger.critical(ErrorCode.ADDON_CONTRIBUTORS_NOT_STATIC, `wireAddon's contributors must be an array of string literals, got: ${prop.initializer.getText()}`);
226
+ }
227
+ }
220
228
  else if (key === 'globalContent') {
221
229
  globalContent = ts.isStringLiteral(prop.initializer)
222
230
  ? prop.initializer.text
@@ -248,6 +256,7 @@ export function addWireAddon(node, state, logger) {
248
256
  contentBucket,
249
257
  contentGrants,
250
258
  globalContent,
259
+ ...(contributors ? { contributors } : {}),
251
260
  });
252
261
  state.rpc.usedAddons.add(name);
253
262
  state.rpc.wireAddonFiles.add(node.getSourceFile().fileName);
@@ -52,6 +52,12 @@ export declare enum ErrorCode {
52
52
  STUDIO_SCREEN_PATH_DUPLICATE = "PKU357",
53
53
  STUDIO_MESSAGE_KEY_MISSING = "PKU358",
54
54
  STUDIO_BADGE_FUNCTION_NOT_FOUND = "PKU359",
55
+ CONTRIBUTORS_NOT_STATIC = "PKU360",
56
+ CONTRIBUTOR_NAME_DUPLICATED = "PKU361",
57
+ ADDON_CONTRIBUTORS_NOT_STATIC = "PKU362",
58
+ ADDON_CONTRIBUTOR_UNKNOWN = "PKU363",
59
+ ADDON_LEGACY_CONTRIBUTORS = "PKU364",
60
+ STUDIO_SCREEN_AGENT_INVALID = "PKU365",
55
61
  DB_COLUMN_NAME_TYPE_CONTRADICTION = "PKU480",
56
62
  DB_JSON_COLUMN_UNTYPED = "PKU481",
57
63
  DB_FORMAT_ON_NON_STRING = "PKU482",
@@ -55,6 +55,12 @@ export var ErrorCode;
55
55
  ErrorCode["STUDIO_SCREEN_PATH_DUPLICATE"] = "PKU357";
56
56
  ErrorCode["STUDIO_MESSAGE_KEY_MISSING"] = "PKU358";
57
57
  ErrorCode["STUDIO_BADGE_FUNCTION_NOT_FOUND"] = "PKU359";
58
+ ErrorCode["CONTRIBUTORS_NOT_STATIC"] = "PKU360";
59
+ ErrorCode["CONTRIBUTOR_NAME_DUPLICATED"] = "PKU361";
60
+ ErrorCode["ADDON_CONTRIBUTORS_NOT_STATIC"] = "PKU362";
61
+ ErrorCode["ADDON_CONTRIBUTOR_UNKNOWN"] = "PKU363";
62
+ ErrorCode["ADDON_LEGACY_CONTRIBUTORS"] = "PKU364";
63
+ ErrorCode["STUDIO_SCREEN_AGENT_INVALID"] = "PKU365";
58
64
  // Database schema codegen warnings
59
65
  ErrorCode["DB_COLUMN_NAME_TYPE_CONTRADICTION"] = "PKU480";
60
66
  ErrorCode["DB_JSON_COLUMN_UNTYPED"] = "PKU481";
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { inspect, analyzeProgram } from './inspector.js';
2
2
  export { getInitialInspectorState } from './initial-state.js';
3
3
  export { finalizeInspectorState } from './finalize.js';
4
4
  export type { TypesMap } from './types-map.js';
5
- export type { AddonConfig, AuthDefinition, CredentialOverrideMeta, StudioMeta, StudioScreenMeta, StudioNavMeta, AddonContributorRef, AddonContributorsMeta, InspectorDiagnostic, InspectorFilters, InspectorHTTPState, InspectorLogger, InspectorMiddlewareState, InspectorOptions, InspectorPermissionState, InspectorServiceTypes, InspectorState, MetaInputTypes, MiddlewareGroupMeta, MiddlewareRegistration, SchemaRef, } from './types.js';
5
+ export type { AddonConfig, AuthDefinition, CredentialOverrideMeta, StudioMeta, StudioScreenMeta, StudioNavMeta, ContributorRef, ContributorVariantsMeta, ContributorsManifestMeta, OptedInContributorsMeta, InspectorDiagnostic, InspectorFilters, InspectorHTTPState, InspectorLogger, InspectorMiddlewareState, InspectorOptions, InspectorPermissionState, InspectorServiceTypes, InspectorState, MetaInputTypes, MiddlewareGroupMeta, MiddlewareRegistration, SchemaRef, } from './types.js';
6
6
  export type { FilesAndMethodsErrors } from './utils/get-files-and-methods.js';
7
7
  export { ErrorCode } from './error-codes.js';
8
8
  export type { DiagnosticSeverity, CodedDiagnostic } from './error-codes.js';
package/dist/types.d.ts CHANGED
@@ -531,14 +531,14 @@ export type CredentialOverrideMeta = string | {
531
531
  name?: string;
532
532
  mode?: 'singleton' | 'wire';
533
533
  };
534
- /** A screen's nav entry as `pikkuStudio` declares it, read statically. */
534
+ /** A screen's nav entry as `definePlugin` declares it, read statically. */
535
535
  export type StudioNavMeta = {
536
536
  area: 'rail' | 'team' | 'project' | 'account';
537
537
  icon: string;
538
538
  order: number;
539
539
  badge?: string;
540
540
  };
541
- /** A screen of an addon as `pikkuStudio` declares it, read statically. */
541
+ /** A screen of an addon as `definePlugin` declares it, read statically. */
542
542
  export type StudioScreenMeta = {
543
543
  path: string;
544
544
  title: string;
@@ -548,6 +548,11 @@ export type StudioScreenMeta = {
548
548
  needsApp?: boolean;
549
549
  layout?: 'bare';
550
550
  scopes?: string[];
551
+ agent?: string;
552
+ /** Message key from the agent file's frontmatter, filled by validation. */
553
+ agentTitle?: string;
554
+ /** Message key from the agent file's frontmatter, filled by validation. */
555
+ agentDescription?: string;
551
556
  } & ({
552
557
  component: string;
553
558
  app?: undefined;
@@ -557,17 +562,40 @@ export type StudioScreenMeta = {
557
562
  });
558
563
  export type StudioMeta = {
559
564
  title: string;
560
- /** The source file whose `pikkuStudio` call declared this manifest. */
565
+ /** The source file whose `definePlugin` call declared this manifest. */
561
566
  file: string;
562
567
  screens: StudioScreenMeta[];
563
568
  /**
564
569
  * The addon's role: every scope its own functions require, derived by
565
- * `pikku all` and never declared. Absent from the raw `pikkuStudio` read.
570
+ * `pikku all` and never declared. Absent from the raw `definePlugin` read.
566
571
  * On a wired instance it also carries the host's `wireAddon` scopes, which
567
572
  * the runner demands of every function in the package.
568
573
  */
569
574
  scopes?: string[];
570
575
  };
576
+ /**
577
+ * Where a contributor variant lives, so a host can import only that variant.
578
+ * `module` is the source file that declares it (absolute while it is in
579
+ * inspector state, relative to the addon package in the published meta);
580
+ * `export` is the name that module exports it under.
581
+ */
582
+ export type ContributorRef = {
583
+ module: string;
584
+ export: string;
585
+ };
586
+ /** One `defineContributors` entry: each variant optional, each its own import. */
587
+ export type ContributorVariantsMeta = {
588
+ dev?: ContributorRef;
589
+ deploy?: ContributorRef;
590
+ };
591
+ /** The contributors an addon registers, keyed by name. */
592
+ export type ContributorsManifestMeta = Record<string, ContributorVariantsMeta>;
593
+ /** The contributors a project opted in to for one wired addon. */
594
+ export type OptedInContributorsMeta = {
595
+ packageName: string;
596
+ /** Each opted-in name with the variants the addon recorded; `module` is relative to the addon package. */
597
+ contributors: ContributorsManifestMeta;
598
+ };
571
599
  export interface InspectorServiceTypes {
572
600
  /** Property names of the project's SingletonServices type, sorted. */
573
601
  singletonServices: string[] | null;
@@ -576,26 +604,15 @@ export interface InspectorServiceTypes {
576
604
  /** Singleton services that are a `SecretService` under another name. */
577
605
  secretAliases: string[];
578
606
  }
579
- export interface AddonContributorRef {
580
- module: string;
581
- export?: string;
582
- }
583
- export interface AddonContributorsMeta {
584
- packageName: string;
585
- packageDir: string;
586
- contributors: AddonContributorRef[];
587
- }
588
607
  export interface InspectorState {
589
- /** Set when this package calls `pikkuStudio`: the screens it ships. */
608
+ /** Set when this package calls `definePlugin`: the screens it ships. */
590
609
  studio?: StudioMeta;
591
- /** The `pikkuStudio` metadata of the addons this project wires with `ui: true`, by namespace. */
610
+ /** The `definePlugin` metadata of the addons this project wires with `ui: true`, by namespace. */
592
611
  addonStudio?: Record<string, StudioMeta>;
593
- /**
594
- * Platform service contributors each wired, non-remote addon declares in its
595
- * `package.json` under `pikku.contributors`, by namespace. `module` resolves
596
- * from `packageDir`.
597
- */
598
- addonContributors?: Record<string, AddonContributorsMeta>;
612
+ /** Set when this package calls `defineContributors`: the contributors it registers, by name. */
613
+ contributorsManifest?: ContributorsManifestMeta;
614
+ /** Per wired addon namespace, the contributors `wireAddon({ contributors })` opted in to. */
615
+ optedInContributors?: Record<string, OptedInContributorsMeta>;
599
616
  rootDir: string;
600
617
  singletonServicesTypeImportMap: PathToNameAndType;
601
618
  wireServicesTypeImportMap: PathToNameAndType;
@@ -793,6 +810,8 @@ export interface InspectorState {
793
810
  * `ContentService` instead of one scoped to its own folder.
794
811
  */
795
812
  globalContent?: string;
813
+ /** The addon contributor names this project opted in to, as `wireAddon({ contributors })` listed them. */
814
+ contributors?: string[];
796
815
  }>;
797
816
  wireAddonFiles: Set<string>;
798
817
  };
@@ -1,4 +1,4 @@
1
- import type { AddonContributorsMeta, InspectorState, InspectorLogger } from '../types.js';
1
+ import type { InspectorState, InspectorLogger } from '../types.js';
2
2
  import type { WebhookSourcesMeta } from '@pikku/core/trigger';
3
3
  export declare const describeMissingAddonMeta: (namespace: string, packageName: string, dirs: string[], declFile?: string) => string;
4
4
  /**
@@ -8,13 +8,6 @@ export declare const describeMissingAddonMeta: (namespace: string, packageName:
8
8
  * Every source starts off: the app turns one on at runtime.
9
9
  */
10
10
  export declare const namespaceAddonWebhookSources: (sources: WebhookSourcesMeta, namespace: string) => WebhookSourcesMeta;
11
- /**
12
- * The platform service contributors an addon declares in its package.json,
13
- * `"pikku": { "contributors": [{ "module", "export" }] }` — the same shape as
14
- * `dev.contributors`. `wireAddon` has no place for them: a contributor emits
15
- * code into the host's entry, so it belongs to the package, not the wiring.
16
- */
17
- export declare const readAddonContributors: (dirs: string[], packageName: string) => Promise<AddonContributorsMeta | null>;
18
11
  export declare function loadAddonFunctionsMeta(logger: InspectorLogger, state: InspectorState): Promise<void>;
19
12
  /**
20
13
  * Load addon schemas into state.schemas. Called after generateAllSchemas
@@ -172,41 +172,62 @@ const loadStudioMeta = async (require, packageName) => {
172
172
  return null;
173
173
  return JSON.parse(await readFile(path, 'utf-8'));
174
174
  };
175
- const isContributorRef = (value) => !!value &&
176
- typeof value === 'object' &&
177
- typeof value.module === 'string' &&
178
- (value.export === undefined ||
179
- typeof value.export === 'string');
175
+ const loadContributorsManifest = async (require, packageName) => {
176
+ const path = resolveAddonMeta(require, packageName, 'contributors/pikku-contributors-meta');
177
+ if (!path)
178
+ return {};
179
+ const meta = JSON.parse(await readFile(path, 'utf-8'));
180
+ return (meta?.contributors ?? {});
181
+ };
180
182
  /**
181
- * The platform service contributors an addon declares in its package.json,
182
- * `"pikku": { "contributors": [{ "module", "export" }] }` — the same shape as
183
- * `dev.contributors`. `wireAddon` has no place for them: a contributor emits
184
- * code into the host's entry, so it belongs to the package, not the wiring.
183
+ * Resolves `wireAddon({ contributors })` against what the addon registered.
184
+ * A name the addon does not register is an error rather than a skip: the
185
+ * project asked for a service and would otherwise build without it.
185
186
  */
186
- export const readAddonContributors = async (dirs, packageName) => {
187
- const packageDir = findInstalledPackageDir(dirs, packageName);
188
- if (!packageDir)
189
- return null;
190
- let declared;
187
+ const recordOptedInContributors = async (logger, state, require, namespace, packageName, names) => {
188
+ let registered;
191
189
  try {
192
- const pkg = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf-8'));
193
- declared = pkg?.pikku?.contributors;
190
+ registered = await loadContributorsManifest(require, packageName);
194
191
  }
195
- catch {
196
- return null;
192
+ catch (e) {
193
+ logger.critical(ErrorCode.ADDON_CONTRIBUTOR_UNKNOWN, `wireAddon('${namespace}') opts in to contributors, but the contributors manifest of ${packageName} could not be read: ${e instanceof Error ? e.message : e}`);
194
+ return;
197
195
  }
198
- if (declared === undefined)
199
- return null;
200
- if (!Array.isArray(declared) || !declared.every(isContributorRef)) {
201
- throw new Error(`${packageName} declares pikku.contributors in its package.json, but it is not a list of { module, export? }.`);
196
+ const selected = {};
197
+ for (const name of names) {
198
+ if (!Object.hasOwn(registered, name)) {
199
+ const known = Object.keys(registered);
200
+ logger.critical(ErrorCode.ADDON_CONTRIBUTOR_UNKNOWN, `wireAddon('${namespace}') opts in to contributor '${name}', but ${packageName} registers no such contributor. ${known.length > 0
201
+ ? `It registers: ${known.join(', ')}.`
202
+ : 'It registers none; add a defineContributors call to the package and run pikku all in it.'}`);
203
+ continue;
204
+ }
205
+ selected[name] = registered[name];
206
+ }
207
+ if (Object.keys(selected).length > 0) {
208
+ ;
209
+ (state.optedInContributors ??= {})[namespace] = {
210
+ packageName,
211
+ contributors: selected,
212
+ };
213
+ }
214
+ };
215
+ /**
216
+ * `pikku.contributors` in an addon's package.json was the string-emitting
217
+ * contributor route. It is no longer read, so it is reported rather than left
218
+ * to stop applying silently.
219
+ */
220
+ const hasLegacyContributors = async (dirs, packageName) => {
221
+ const dir = findInstalledPackageDir(dirs, packageName);
222
+ if (!dir)
223
+ return false;
224
+ try {
225
+ const pkg = JSON.parse(await readFile(join(dir, 'package.json'), 'utf-8'));
226
+ return pkg?.pikku?.contributors !== undefined;
227
+ }
228
+ catch {
229
+ return false;
202
230
  }
203
- if (declared.length === 0)
204
- return null;
205
- return {
206
- packageName,
207
- packageDir,
208
- contributors: declared.map(({ module, export: name }) => name === undefined ? { module } : { module, export: name }),
209
- };
210
231
  };
211
232
  /**
212
233
  * A versioned remote addon's function metadata comes from the SDK `pikku addon
@@ -275,17 +296,13 @@ export async function loadAddonFunctionsMeta(logger, state) {
275
296
  const dirs = addonResolutionDirs(state.rootDir, decl.file);
276
297
  const require = createAddonResolver(dirs);
277
298
  try {
278
- try {
279
- const contributors = await readAddonContributors(dirs, decl.package);
280
- if (contributors) {
281
- ;
282
- (state.addonContributors ??= {})[namespace] = contributors;
283
- }
299
+ recordDeclaredUses(logger, namespace, decl, dirs);
300
+ if (decl.contributors && decl.contributors.length > 0) {
301
+ await recordOptedInContributors(logger, state, require, namespace, decl.package, decl.contributors);
284
302
  }
285
- catch (error) {
286
- logger.error(`wireAddon('${namespace}'): ${error instanceof Error ? error.message : error}`);
303
+ if (await hasLegacyContributors(dirs, decl.package)) {
304
+ logger.critical(ErrorCode.ADDON_LEGACY_CONTRIBUTORS, `${decl.package} still declares "pikku.contributors" in its package.json, which is no longer read. Remove it, register the contributors with defineContributors in the addon's source, and opt in with wireAddon('${namespace}', { contributors: [...] }). See docs/contributors.md.`);
287
305
  }
288
- recordDeclaredUses(logger, namespace, decl, dirs);
289
306
  if (decl.ui) {
290
307
  let manifest;
291
308
  let unreadable = false;
@@ -300,7 +317,7 @@ export async function loadAddonFunctionsMeta(logger, state) {
300
317
  // reported above; the addon's functions and scopes still load
301
318
  }
302
319
  else if (!manifest || manifest.screens.length === 0) {
303
- logger.critical(ErrorCode.ADDON_UI_HAS_NO_SCREENS, `wireAddon('${namespace}') sets ui: true, but ${decl.package} declares no screens. Add a pikkuStudio call to the package, or drop ui.`);
320
+ logger.critical(ErrorCode.ADDON_UI_HAS_NO_SCREENS, `wireAddon('${namespace}') sets ui: true, but ${decl.package} declares no screens. Add a definePlugin call to the package, or drop ui.`);
304
321
  }
305
322
  else {
306
323
  ;
@@ -212,6 +212,7 @@ export interface SerializableInspectorState {
212
212
  contentBucket?: string;
213
213
  contentGrants?: Record<string, string>;
214
214
  globalContent?: string;
215
+ contributors?: string[];
215
216
  }
216
217
  ]>;
217
218
  wireAddonFiles: string[];
@@ -331,7 +332,8 @@ export interface SerializableInspectorState {
331
332
  remoteAddonFunctions?: InspectorState['remoteAddonFunctions'];
332
333
  studio?: InspectorState['studio'];
333
334
  addonStudio?: InspectorState['addonStudio'];
334
- addonContributors?: InspectorState['addonContributors'];
335
+ contributorsManifest?: InspectorState['contributorsManifest'];
336
+ optedInContributors?: InspectorState['optedInContributors'];
335
337
  exportedContracts: InspectorState['exportedContracts'];
336
338
  }
337
339
  /**
@@ -196,7 +196,8 @@ export function serializeInspectorState(state) {
196
196
  remoteAddonFunctions: state.remoteAddonFunctions,
197
197
  studio: state.studio,
198
198
  addonStudio: state.addonStudio,
199
- addonContributors: state.addonContributors,
199
+ contributorsManifest: state.contributorsManifest,
200
+ optedInContributors: state.optedInContributors,
200
201
  exportedContracts: state.exportedContracts,
201
202
  };
202
203
  }
@@ -406,7 +407,8 @@ export function deserializeInspectorState(data) {
406
407
  : {}),
407
408
  studio: data.studio,
408
409
  addonStudio: data.addonStudio,
409
- addonContributors: data.addonContributors,
410
+ contributorsManifest: data.contributorsManifest,
411
+ optedInContributors: data.optedInContributors,
410
412
  exportedContracts: data.exportedContracts || {
411
413
  http: {},
412
414
  cli: {},
package/dist/visit.js CHANGED
@@ -16,6 +16,7 @@ import { addGateway } from './add/add-gateway.js';
16
16
  import { addRPCInvocations } from './add/add-rpc-invocations.js';
17
17
  import { addWireAddon } from './add/add-wire-addon.js';
18
18
  import { addPikkuStudio } from './add/add-pikku-studio.js';
19
+ import { addDefineContributors } from './add/add-define-contributors.js';
19
20
  import { addWireRemoteAddon } from './add/add-wire-remote-addon.js';
20
21
  import { addMiddleware } from './add/add-middleware.js';
21
22
  import { addPermission } from './add/add-permission.js';
@@ -52,6 +53,7 @@ export const visitSetup = (logger, checker, node, state, options) => {
52
53
  addRPCInvocations(node, state, logger);
53
54
  addWireAddon(node, state, logger);
54
55
  addPikkuStudio(node, state, logger);
56
+ addDefineContributors(logger, node, checker, state);
55
57
  addWireRemoteAddon(node, state, logger);
56
58
  addMiddleware(logger, node, checker, state, options);
57
59
  addPermission(logger, node, checker, state, options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.111",
3
+ "version": "0.12.113",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -70,10 +70,10 @@
70
70
  "zod": "^4.6.7"
71
71
  },
72
72
  "peerDependencies": {
73
- "@pikku/core": "^0.12.147"
73
+ "@pikku/core": "^0.12.150"
74
74
  },
75
75
  "devDependencies": {
76
- "@pikku/core": "^0.12.147",
76
+ "@pikku/core": "^0.12.150",
77
77
  "@types/node": "^26.6.5"
78
78
  },
79
79
  "engines": {