@celilo/cli 1.11.0 → 1.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,6 +61,7 @@ interface ManifestForPublish extends ManifestForCapabilityCheck {
61
61
  id: string;
62
62
  version: string;
63
63
  description?: string;
64
+ icon?: string;
64
65
  version_source?: { kind?: string };
65
66
  }
66
67
 
@@ -307,6 +308,7 @@ export async function publishOneModule(
307
308
  netappPath: buildResult.packagePath,
308
309
  token: opts.token,
309
310
  description: manifest.description?.trim() || undefined,
311
+ icon: manifest.icon?.trim() || undefined,
310
312
  });
311
313
  } catch (err) {
312
314
  return {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The `icon` field's refinement (openspec/changes/module-icons, D3).
3
+ *
4
+ * This is the trust boundary: `manifest.yml` is hand-edited and the value ends
5
+ * up drawn in a coloured row, so the check that it is monochrome-capable lives
6
+ * here rather than in any consumer.
7
+ */
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { ModuleManifestSchema } from './schema';
11
+
12
+ function manifestWith(icon?: string): Record<string, unknown> {
13
+ return {
14
+ celilo_contract: '1.0',
15
+ id: 'icon-fixture',
16
+ name: 'Icon Fixture',
17
+ version: '1.0.0',
18
+ ...(icon === undefined ? {} : { icon }),
19
+ };
20
+ }
21
+
22
+ describe('manifest icon', () => {
23
+ test('accepts a BMP non-emoji scalar', () => {
24
+ const parsed = ModuleManifestSchema.parse(manifestWith('⛨'));
25
+ expect(parsed.icon).toBe('⛨');
26
+ });
27
+
28
+ test('accepts absence', () => {
29
+ const parsed = ModuleManifestSchema.parse(manifestWith());
30
+ expect(parsed.icon).toBeUndefined();
31
+ });
32
+
33
+ test('rejects U+1F512, naming the monochrome reason rather than the range', () => {
34
+ const result = ModuleManifestSchema.safeParse(manifestWith('🔒'));
35
+ expect(result.success).toBe(false);
36
+ if (result.success) throw new Error('expected the padlock to be rejected');
37
+ const message = result.error.issues[0]?.message ?? '';
38
+ expect(message).toContain('monochrome');
39
+ expect(message).toContain('U+1F512');
40
+ });
41
+
42
+ test('rejects a two-character string', () => {
43
+ const result = ModuleManifestSchema.safeParse(manifestWith('⛨⛨'));
44
+ expect(result.success).toBe(false);
45
+ if (result.success) throw new Error('expected two characters to be rejected');
46
+ expect(result.error.issues[0]?.message ?? '').toContain('exactly one character');
47
+ });
48
+ });
@@ -563,6 +563,14 @@ export type ModuleSubscription = z.infer<typeof ModuleSubscriptionSchema>;
563
563
  * with via `celilo_contract`. The contract version determines the
564
564
  * canonical inputs/outputs of every lifecycle hook (see `./contracts/v1.ts`).
565
565
  */
566
+ /**
567
+ * Codepoints carrying Unicode's `Emoji` property, which the `icon` field
568
+ * rejects. Note this property is broader than "looks like an emoji": ASCII
569
+ * digits, `#` and `*` carry it too, because they form keycap sequences. That
570
+ * over-rejection costs nothing — none of them is a plausible module glyph.
571
+ */
572
+ const EMOJI_CODEPOINT = /\p{Emoji}/u;
573
+
566
574
  export const ModuleManifestSchema = z
567
575
  .object({
568
576
  /**
@@ -584,6 +592,55 @@ export const ModuleManifestSchema = z
584
592
  version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Version must be semantic version (e.g., 1.0.0)'),
585
593
  description: z.string().optional(),
586
594
 
595
+ /**
596
+ * One glyph identifying this module wherever celilo draws it — the console
597
+ * roster, the topology boxes, the registry browse page. Optional: a module
598
+ * declaring none falls back to the consumer's built-in table, then to a
599
+ * placeholder (openspec/changes/module-icons, D3/D5).
600
+ *
601
+ * Exactly one Unicode scalar, inside the BMP, without Unicode's `Emoji`
602
+ * property. These glyphs inherit the colour of the row they are drawn in,
603
+ * so a firing module's icon goes red with the rest of the row. An emoji
604
+ * codepoint paints its own colours and would stay cheerful while its
605
+ * module's state said otherwise.
606
+ *
607
+ * BMP-and-not-Emoji is a PROXY for "renders monochrome", not a proof. Some
608
+ * BMP codepoints outside the Emoji property still get an emoji font on some
609
+ * platforms. What it does catch is the whole SMP emoji range, which is
610
+ * where an author reaching for a padlock or a shield actually lands, and
611
+ * that is the case worth catching.
612
+ *
613
+ * `ModuleManifestSchema` is strict, so a celilo predating this field
614
+ * rejects a manifest declaring it. The CLI release accepting `icon` ships
615
+ * before any module published to the registry declares one (D2).
616
+ */
617
+ icon: z
618
+ .string()
619
+ .superRefine((value, ctx) => {
620
+ const scalars = [...value];
621
+ if (scalars.length !== 1) {
622
+ ctx.addIssue({
623
+ code: z.ZodIssueCode.custom,
624
+ message: `icon must be exactly one character, got ${scalars.length}`,
625
+ });
626
+ return;
627
+ }
628
+ const codePoint = value.codePointAt(0) ?? 0;
629
+ if (codePoint > 0xffff || EMOJI_CODEPOINT.test(value)) {
630
+ const hex = codePoint.toString(16).toUpperCase().padStart(4, '0');
631
+ ctx.addIssue({
632
+ code: z.ZodIssueCode.custom,
633
+ message: [
634
+ `icon '${value}' (U+${hex}) must be a monochrome glyph: it inherits the colour`,
635
+ 'of the row it is drawn in, and an emoji codepoint paints its own colours, so it',
636
+ "would stay cheerful while the module went red. Use a BMP symbol outside Unicode's",
637
+ "Emoji property (a key '\u26bf', not a padlock '\u{1f512}').",
638
+ ].join(' '),
639
+ });
640
+ }
641
+ })
642
+ .optional(),
643
+
587
644
  /**
588
645
  * How `manifest.yml#version` (the PAYLOAD version) is determined — see
589
646
  * openspec/changes/module-version-semantics/proposal.md / ISS-0151. The capability *contract* version
@@ -28,6 +28,8 @@ export interface SearchResult {
28
28
  name: string;
29
29
  max_version: string;
30
30
  description: string;
31
+ /** The module's declared glyph, absent when it declared none. */
32
+ icon?: string;
31
33
  }
32
34
 
33
35
  export interface SearchResponse {
@@ -130,6 +132,12 @@ export class RegistryClient {
130
132
  * apps/celilo/designs/REGISTRY_BROWSE_UI.md (Phase 2 step 0).
131
133
  */
132
134
  description?: string;
135
+ /**
136
+ * The module's `manifest.yml#icon`, when it declares one. Optional and
137
+ * server-tolerated in its absence, exactly like `description`
138
+ * (openspec/changes/module-icons, D4).
139
+ */
140
+ icon?: string;
133
141
  }): Promise<{ ok: boolean; name: string; vers: string }> {
134
142
  const fileData = await readFile(opts.netappPath);
135
143
  const cksum = `sha256:${createHash('sha256').update(fileData).digest('hex')}`;
@@ -140,6 +148,7 @@ export class RegistryClient {
140
148
  deps: [],
141
149
  cksum,
142
150
  ...(opts.description ? { description: opts.description } : {}),
151
+ ...(opts.icon ? { icon: opts.icon } : {}),
143
152
  });
144
153
  const metaBuf = Buffer.from(meta, 'utf-8');
145
154