acrylblends 1.3.2 → 1.4.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/legacy/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # The scaffolds published acrylblends releases shipped
2
2
 
3
3
  Generated by `scripts/build-legacy-layouts.mjs`; do not edit by hand. Each folder is a template, each hash under it one distinct scaffold (deduplicated by
4
- content hash across releases), and `layouts.json` maps every published release that shipped templates (0.3.0 through 1.3.1, 43 releases)
4
+ content hash across releases), and `layouts.json` maps every published release that shipped templates (0.3.0 through 1.3.2, 44 releases)
5
5
  to the layouts it shipped.
6
6
 
7
7
  `acrylblends upgrade` compares an app's scaffold files with these bytes: a file the app has not edited is brought up to date, an edited one is refused (or
@@ -419,6 +419,17 @@
419
419
  "notes": "fb43a1c5f3b4",
420
420
  "todo": "164274ffcf7f"
421
421
  }
422
+ },
423
+ {
424
+ "version": "1.3.2",
425
+ "layouts": {
426
+ "agent": "ecbc84f1c1e5",
427
+ "blank": "a7e8f539ffee",
428
+ "chat": "d4464dfb3b04",
429
+ "nodejs": "a6a340b4d3a6",
430
+ "notes": "fb43a1c5f3b4",
431
+ "todo": "164274ffcf7f"
432
+ }
422
433
  }
423
434
  ],
424
435
  "templates": {
@@ -102,12 +102,9 @@ interface PluginSharePort {
102
102
  list(): readonly unknown[];
103
103
  /** Take a plugin out and forget its record; a pulled folder still holding the release as pulled goes too. */
104
104
  remove(name: string): Promise<unknown>;
105
- pull(source: string): Promise<{
106
- readonly ok: boolean;
107
- readonly action?: string;
108
- readonly reason?: string;
109
- readonly next?: string;
110
- }>;
105
+ pull(source: string): Promise<PullView>;
106
+ /** Pull each source of a group's list on its own: one that is refused does not stop the others. */
107
+ pullList(from: string): Promise<PullListView>;
111
108
  planPublish(name: string, options?: PublishOptionsView): Promise<PublishPlanView | {
112
109
  readonly ok: false;
113
110
  readonly reason: string;
@@ -118,6 +115,28 @@ interface PluginSharePort {
118
115
  readonly reason?: string;
119
116
  }>;
120
117
  }
118
+ /** What one pull did, or why it was refused. */
119
+ interface PullView {
120
+ readonly ok: boolean;
121
+ readonly action?: string;
122
+ readonly name?: string;
123
+ readonly reason?: string;
124
+ readonly next?: string;
125
+ }
126
+ /** What a list pull did for each source, or why the list itself was refused (then nothing was pulled). */
127
+ type PullListView = {
128
+ readonly ok: true;
129
+ readonly from: string;
130
+ readonly entries: readonly {
131
+ readonly line: number;
132
+ readonly source: string;
133
+ readonly result: PullView;
134
+ }[];
135
+ } | {
136
+ readonly ok: false;
137
+ readonly reason: string;
138
+ readonly next: string;
139
+ };
121
140
  /** Who a plan publishes as, and under which license; the service's defaults when absent. */
122
141
  interface PublishOptionsView {
123
142
  readonly owner?: string;
@@ -223,6 +242,8 @@ declare class Door {
223
242
  /** The person's yes, from their own terminal. Not a verb: MCP and the chat do not have it. */
224
243
  publishConfirmed(name: string, confirmed: PublishPlanView, options: PublishOptionsView): Promise<unknown>;
225
244
  private pull;
245
+ /** A group's list: every source is pulled on its own, so one refused plugin is named with its reason and the others still install and run. */
246
+ private pullList;
226
247
  /** A folder named by an agent, resolved from the app folder; it must be inside the app. */
227
248
  private insideApp;
228
249
  private offeredTools;
@@ -464,15 +464,20 @@ const VERB_SPECS = {
464
464
  }
465
465
  },
466
466
  "plugin-pull": {
467
- usage: "plugin pull <source>: fetch a plugin someone released (github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz), check it and start it, live",
468
- description: "Fetch a plugin someone released, without running anything; check its digest, that it works on every OS and that this app has the services it needs; then install and start it without a restart. A refusal says why and changes nothing.",
467
+ usage: "plugin pull <source> | --from <list>: fetch a plugin someone released (github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz), or every plugin of a group's list, check each and start it, live",
468
+ description: "Fetch a plugin someone released, without running anything; check its digest, that it works on every OS and that this app has the services it needs; then install and start it without a restart. A refusal says why and changes nothing. With from instead of source, pull every source of a group's list (one per line, # comments); each is pulled on its own, so a refused one is named with its reason and the others still come in.",
469
469
  arguments: {
470
470
  type: "object",
471
- properties: { source: {
472
- type: "string",
473
- description: "github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz path or https address"
474
- } },
475
- required: ["source"],
471
+ properties: {
472
+ source: {
473
+ type: "string",
474
+ description: "github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz path or https address"
475
+ },
476
+ from: {
477
+ type: "string",
478
+ description: "instead of source: a list of sources, a file in the app (e.g. group.txt) or an https address"
479
+ }
480
+ },
476
481
  additionalProperties: false
477
482
  }
478
483
  },
@@ -708,7 +713,7 @@ var Door = class {
708
713
  }
709
714
  case "plugin-list": return this.plugin((plugins) => plugins.list(), false, this.installed());
710
715
  case "plugin-add": return this.plugin((extensions) => extensions.install(this.insideApp(input.folder)));
711
- case "plugin-pull": return this.pull(input.source);
716
+ case "plugin-pull": return input.from === void 0 ? this.pull(input.source) : this.pullList(input.from, input.source);
712
717
  case "plugin-publish": return this.planPublish(input);
713
718
  case "plugin-remove": {
714
719
  const name = input.name;
@@ -997,13 +1002,29 @@ var Door = class {
997
1002
  async pull(source) {
998
1003
  if (typeof source !== "string" || source === "") throw new DoorError("invalid", "plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0");
999
1004
  const value = await this.share().pull(source);
1000
- if (!value.ok) throw new DoorError("refused", `${value.reason ?? "the pull was refused"}. ${value.next ?? "nothing changed"}.`);
1005
+ if (!value.ok) throw new DoorError("refused", `${sentence(value.reason ?? "the pull was refused")} ${sentence(value.next ?? "nothing changed")}`);
1001
1006
  if (value.action !== "unchanged" && this.deps.reloadOnChange !== false) await this.deps.channel.reloadAll();
1002
1007
  return {
1003
1008
  value,
1004
1009
  text: JSON.stringify(value, null, 2)
1005
1010
  };
1006
1011
  }
1012
+ /** A group's list: every source is pulled on its own, so one refused plugin is named with its reason and the others still install and run. */
1013
+ async pullList(from, source) {
1014
+ if (source !== void 0) throw new DoorError("invalid", "plugin pull takes a source or --from <list>, not both");
1015
+ if (typeof from !== "string" || from === "") throw new DoorError("invalid", "plugin pull --from needs a list, e.g. plugin pull --from group.txt");
1016
+ const value = await this.share().pullList(from);
1017
+ if (!value.ok) throw new DoorError("refused", `${sentence(value.reason)} ${sentence(value.next)}`);
1018
+ if (value.entries.some((entry) => entry.result.ok && entry.result.action !== "unchanged") && this.deps.reloadOnChange !== false) await this.deps.channel.reloadAll();
1019
+ const lines = value.entries.map((entry) => `${entry.source}: ${pullLine(entry.result)}`);
1020
+ const refused = value.entries.filter((entry) => !entry.result.ok);
1021
+ const summary = `${String(value.entries.length - refused.length)} of ${String(value.entries.length)} from ${value.from} pulled`;
1022
+ if (refused.length > 0) throw new DoorError("refused", [`${summary}; ${String(refused.length)} refused, the others are in and running.`, ...lines].join("\n"));
1023
+ return {
1024
+ value,
1025
+ text: [`${summary}.`, ...lines].join("\n")
1026
+ };
1027
+ }
1007
1028
  /** A folder named by an agent, resolved from the app folder; it must be inside the app. */
1008
1029
  insideApp(folder) {
1009
1030
  if (typeof folder !== "string" || folder === "") throw new DoorError("invalid", "plugin add needs a folder, e.g. plugins/pomodoro");
@@ -1040,6 +1061,16 @@ var Door = class {
1040
1061
  };
1041
1062
  }
1042
1063
  };
1064
+ /** One source's outcome in a list pull, as a person reads it. */
1065
+ function pullLine(result) {
1066
+ if (!result.ok) return `refused: ${sentence(result.reason ?? "the pull was refused")} ${sentence(result.next ?? "nothing changed")}`;
1067
+ return `${result.action ?? "pulled"}${result.name === void 0 ? "" : ` ${result.name}`}`;
1068
+ }
1069
+ /** A text as one sentence: the full stop added only when it has no closing punctuation of its own (a reason may end with one, a next step often does). */
1070
+ function sentence(text) {
1071
+ const trimmed = text.trim();
1072
+ return /[.!?]$/u.test(trimmed) ? trimmed : `${trimmed}.`;
1073
+ }
1043
1074
  //#endregion
1044
1075
  //#region ../../plugins/acryl-door/src/host/chat-tools.ts
1045
1076
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acrylblends",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "description": "The Acryl web framework (import { Acryl } from \"acrylblends\") and the acrylblends command that scaffolds Cordis apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,7 +17,7 @@ import { fileURLToPath } from 'node:url'
17
17
  import { readDoorFile, STATE_DIR, DOOR_FILE } from '../host/door-file.ts'
18
18
 
19
19
  const ACT_OPS = new Set(['click', 'type', 'select', 'press', 'scroll', 'wait', 'reload', 'navigate'])
20
- const VALUED = new Set(['--dir', '--owner', '--license', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--id', '--width', '--height', '--window', '--tool', '--args', '--path'])
20
+ const VALUED = new Set(['--dir', '--owner', '--license', '--ref', '--text', '--option', '--key', '--direction', '--amount', '--cursor', '--max-nodes', '--role', '--name', '--timeout-ms', '--id', '--width', '--height', '--window', '--tool', '--args', '--path', '--from'])
21
21
  const FLAGS = new Set(['--json', '--submit', '--no-clear', '--gone', '--force', '--help'])
22
22
 
23
23
  export class UsageError extends Error {}
@@ -139,7 +139,12 @@ export function parseInvocation(argv: readonly string[], env: NodeJS.ProcessEnv
139
139
  case 'plugin': {
140
140
  if (second === 'list' || second === 'reload') return make(`plugin-${second}`)
141
141
  if (second === 'add') { if (third === undefined) throw new UsageError('plugin add needs a folder, e.g. plugin add plugins/pomodoro'); return make('plugin-add', { folder: third }) }
142
- if (second === 'pull') { if (third === undefined) throw new UsageError('plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0'); return make('plugin-pull', { source: third }) }
142
+ if (second === 'pull') {
143
+ const from = values.get('--from')
144
+ if (from !== undefined) { if (third !== undefined) throw new UsageError('plugin pull takes a source or --from <list>, not both'); return make('plugin-pull', { from }) }
145
+ if (third === undefined) throw new UsageError('plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0, or --from <list>')
146
+ return make('plugin-pull', { source: third })
147
+ }
143
148
  if (second === 'publish') {
144
149
  if (third === undefined) throw new UsageError('plugin publish needs a plugin name, e.g. plugin publish pomodoro')
145
150
  const owner = values.get('--owner')
@@ -41,11 +41,21 @@ export interface PluginSharePort {
41
41
  list(): readonly unknown[]
42
42
  /** Take a plugin out and forget its record; a pulled folder still holding the release as pulled goes too. */
43
43
  remove(name: string): Promise<unknown>
44
- pull(source: string): Promise<{ readonly ok: boolean; readonly action?: string; readonly reason?: string; readonly next?: string }>
44
+ pull(source: string): Promise<PullView>
45
+ /** Pull each source of a group's list on its own: one that is refused does not stop the others. */
46
+ pullList(from: string): Promise<PullListView>
45
47
  planPublish(name: string, options?: PublishOptionsView): Promise<PublishPlanView | { readonly ok: false; readonly reason: string; readonly next: string }>
46
48
  publish(name: string, confirmed: PublishPlanView, options?: PublishOptionsView): Promise<{ readonly ok: boolean; readonly reason?: string }>
47
49
  }
48
50
 
51
+ /** What one pull did, or why it was refused. */
52
+ export interface PullView { readonly ok: boolean; readonly action?: string; readonly name?: string; readonly reason?: string; readonly next?: string }
53
+
54
+ /** What a list pull did for each source, or why the list itself was refused (then nothing was pulled). */
55
+ export type PullListView =
56
+ | { readonly ok: true; readonly from: string; readonly entries: readonly { readonly line: number; readonly source: string; readonly result: PullView }[] }
57
+ | { readonly ok: false; readonly reason: string; readonly next: string }
58
+
49
59
  /** Who a plan publishes as, and under which license; the service's defaults when absent. */
50
60
  export interface PublishOptionsView {
51
61
  readonly owner?: string
@@ -159,7 +169,7 @@ export class Door {
159
169
  }
160
170
  case 'plugin-list': return this.plugin(plugins => plugins.list(), false, this.installed())
161
171
  case 'plugin-add': return this.plugin(extensions => extensions.install(this.insideApp(input.folder)))
162
- case 'plugin-pull': return this.pull(input.source)
172
+ case 'plugin-pull': return input.from === undefined ? this.pull(input.source) : this.pullList(input.from, input.source)
163
173
  case 'plugin-publish': return this.planPublish(input)
164
174
  case 'plugin-remove': {
165
175
  const name = input.name
@@ -388,12 +398,28 @@ export class Door {
388
398
  if (typeof source !== 'string' || source === '') throw new DoorError('invalid', 'plugin pull needs a source, e.g. plugin pull github:someone/pomodoro@0.1.0')
389
399
  const value = await this.share().pull(source)
390
400
  // A refusal is a failed command, so a script does not read it as done; it changed nothing, and the open pages stay.
391
- if (!value.ok) throw new DoorError('refused', `${value.reason ?? 'the pull was refused'}. ${value.next ?? 'nothing changed'}.`)
401
+ if (!value.ok) throw new DoorError('refused', `${sentence(value.reason ?? 'the pull was refused')} ${sentence(value.next ?? 'nothing changed')}`)
392
402
  // Neither did pulling what is already there change anything.
393
403
  if (value.action !== 'unchanged' && this.deps.reloadOnChange !== false) await this.deps.channel.reloadAll()
394
404
  return { value, text: JSON.stringify(value, null, 2) }
395
405
  }
396
406
 
407
+ /** A group's list: every source is pulled on its own, so one refused plugin is named with its reason and the others still install and run. */
408
+ private async pullList(from: unknown, source: unknown): Promise<VerbAnswer> {
409
+ if (source !== undefined) throw new DoorError('invalid', 'plugin pull takes a source or --from <list>, not both')
410
+ if (typeof from !== 'string' || from === '') throw new DoorError('invalid', 'plugin pull --from needs a list, e.g. plugin pull --from group.txt')
411
+ const value = await this.share().pullList(from)
412
+ if (!value.ok) throw new DoorError('refused', `${sentence(value.reason)} ${sentence(value.next)}`)
413
+ const changed = value.entries.some(entry => entry.result.ok && entry.result.action !== 'unchanged')
414
+ if (changed && this.deps.reloadOnChange !== false) await this.deps.channel.reloadAll()
415
+ const lines = value.entries.map(entry => `${entry.source}: ${pullLine(entry.result)}`)
416
+ const refused = value.entries.filter(entry => !entry.result.ok)
417
+ const summary = `${String(value.entries.length - refused.length)} of ${String(value.entries.length)} from ${value.from} pulled`
418
+ // Some refused: the command failed, so a script sees it, but the ones that came in stay in and keep running.
419
+ if (refused.length > 0) throw new DoorError('refused', [`${summary}; ${String(refused.length)} refused, the others are in and running.`, ...lines].join('\n'))
420
+ return { value, text: [`${summary}.`, ...lines].join('\n') }
421
+ }
422
+
397
423
  /** A folder named by an agent, resolved from the app folder; it must be inside the app. */
398
424
  private insideApp(folder: unknown): string {
399
425
  if (typeof folder !== 'string' || folder === '') throw new DoorError('invalid', 'plugin add needs a folder, e.g. plugins/pomodoro')
@@ -426,3 +452,15 @@ export class Door {
426
452
  return { value, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }
427
453
  }
428
454
  }
455
+
456
+ /** One source's outcome in a list pull, as a person reads it. */
457
+ function pullLine(result: PullView): string {
458
+ if (!result.ok) return `refused: ${sentence(result.reason ?? 'the pull was refused')} ${sentence(result.next ?? 'nothing changed')}`
459
+ return `${result.action ?? 'pulled'}${result.name === undefined ? '' : ` ${result.name}`}`
460
+ }
461
+
462
+ /** A text as one sentence: the full stop added only when it has no closing punctuation of its own (a reason may end with one, a next step often does). */
463
+ function sentence(text: string): string {
464
+ const trimmed = text.trim()
465
+ return /[.!?]$/u.test(trimmed) ? trimmed : `${trimmed}.`
466
+ }
@@ -87,9 +87,9 @@ export const VERB_SPECS: Readonly<Record<Verb, VerbSpec>> = {
87
87
  arguments: { type: 'object', properties: { folder: { type: 'string', description: 'a folder inside the app, e.g. plugins/pomodoro' } }, required: ['folder'], additionalProperties: false },
88
88
  },
89
89
  'plugin-pull': {
90
- usage: 'plugin pull <source>: fetch a plugin someone released (github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz), check it and start it, live',
91
- description: 'Fetch a plugin someone released, without running anything; check its digest, that it works on every OS and that this app has the services it needs; then install and start it without a restart. A refusal says why and changes nothing.',
92
- arguments: { type: 'object', properties: { source: { type: 'string', description: 'github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz path or https address' } }, required: ['source'], additionalProperties: false },
90
+ usage: 'plugin pull <source> | --from <list>: fetch a plugin someone released (github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz), or every plugin of a group\'s list, check each and start it, live',
91
+ description: 'Fetch a plugin someone released, without running anything; check its digest, that it works on every OS and that this app has the services it needs; then install and start it without a restart. A refusal says why and changes nothing. With from instead of source, pull every source of a group\'s list (one per line, # comments); each is pulled on its own, so a refused one is named with its reason and the others still come in.',
92
+ arguments: { type: 'object', properties: { source: { type: 'string', description: 'github:<owner>/<repo>[@<version>][@sha256:<digest>], or a .tgz path or https address' }, from: { type: 'string', description: 'instead of source: a list of sources, a file in the app (e.g. group.txt) or an https address' } }, additionalProperties: false },
93
93
  },
94
94
  'plugin-publish': {
95
95
  usage: 'plugin publish <name> [--owner <account>] [--license <id>]: show what publishing this plugin would send; in a person\'s own terminal, ask them',
@@ -116,6 +116,8 @@ export interface ExtensionInfo {
116
116
  readonly missing: readonly string[]
117
117
  /** The source folder no longer exists; the last installed version still runs. */
118
118
  readonly stale: boolean
119
+ /** The routes its mount added, as the plugin wrote them (without the frame's prefix), so a surface can link to its pages. */
120
+ readonly routes: readonly { readonly method: string; readonly path: string }[]
119
121
  }
120
122
 
121
123
  export type InstallResult =
@@ -353,6 +355,7 @@ export class Extensions extends Service {
353
355
  state,
354
356
  missing: state === 'pending' ? this.missing(entry.module) : [],
355
357
  stale: this.stale.has(entry.name),
358
+ routes: entry.routes.map(({ method, path }) => ({ method, path: this.written(path) })),
356
359
  }
357
360
  })
358
361
  .sort((a, b) => a.name.localeCompare(b.name))
@@ -609,6 +612,13 @@ export class Extensions extends Service {
609
612
  return servedRoutes(fibers)
610
613
  }
611
614
 
615
+ /** A served path as the plugin wrote it: without the frame's canvas prefix. */
616
+ private written(path: string): string {
617
+ return this.routePrefix !== '' && path.startsWith(`${this.routePrefix}/`)
618
+ ? path.slice(this.routePrefix.length)
619
+ : path
620
+ }
621
+
612
622
  /**
613
623
  * A route the new plugin added that another plugin already serves: the router would answer with the other one, so the new route would never be
614
624
  * reached. Refused before the plugin's own check, which the other plugin's answer would pass.
@@ -624,15 +634,11 @@ export class Extensions extends Service {
624
634
  [...this.installed.values()].find((entry) =>
625
635
  entry.routes.some((own) => own.method === taken.method && own.path === taken.path),
626
636
  )?.name ?? (taken.owner === '' ? 'the app' : taken.owner)
627
- const shown = (path: string): string =>
628
- this.routePrefix !== '' && path.startsWith(`${this.routePrefix}/`)
629
- ? path.slice(this.routePrefix.length)
630
- : path
631
637
  const same =
632
- taken.path === route.path ? '' : ` (it serves ${taken.method} ${shown(taken.path)})`
638
+ taken.path === route.path ? '' : ` (it serves ${taken.method} ${this.written(taken.path)})`
633
639
  return {
634
640
  stage: 'conflict',
635
- error: `${route.method} ${shown(route.path)} is already served by ${owner}${same}; rename this plugin's route, or remove ${owner} first`,
641
+ error: `${route.method} ${this.written(route.path)} is already served by ${owner}${same}; rename this plugin's route, or remove ${owner} first`,
636
642
  }
637
643
  }
638
644
 
@@ -10,6 +10,7 @@ import {
10
10
  readFileSync,
11
11
  renameSync,
12
12
  rmSync,
13
+ statSync,
13
14
  writeFileSync,
14
15
  } from 'node:fs'
15
16
  import { dirname, join, resolve } from 'node:path'
@@ -54,6 +55,7 @@ import {
54
55
  refuse,
55
56
  splitDigest,
56
57
  } from './pull-check.ts'
58
+ import { LIST_MAX_BYTES, parsePullList, pullListLocation } from './pull-list.ts'
57
59
  import { type PulledRecord, pulledRecords, withPulledRecord } from './pulled-records.ts'
58
60
 
59
61
  export type {
@@ -76,6 +78,8 @@ export interface PluginShareConfig {
76
78
  releases?: ReleasePort
77
79
  /** Where a publish goes. Defaults to the person's own `gh` and `git`. */
78
80
  publish?: PublishPorts
81
+ /** How an https address is read: a release `.tgz` and a list of sources. Defaults to HTTPS with the hub's size and time limits. */
82
+ download?: (url: string) => Promise<Uint8Array>
79
83
  }
80
84
 
81
85
  export type PullResult =
@@ -94,6 +98,19 @@ export type PullResult =
94
98
  }
95
99
  | PullRefused
96
100
 
101
+ /** One source of a list and what pulling it did. */
102
+ export interface PullListEntry {
103
+ /** Its line in the list. */
104
+ readonly line: number
105
+ readonly source: string
106
+ readonly result: PullResult
107
+ }
108
+
109
+ /** A list pulled: every source with its own result (some may be refused while the others install), or the list itself refused. */
110
+ export type PullListResult =
111
+ | { readonly ok: true; readonly from: string; readonly entries: readonly PullListEntry[] }
112
+ | PullRefused
113
+
97
114
  /** One plugin installed while the app runs, as a person reads the list: what it is and, for one pulled, where it came from and who released it. */
98
115
  /** What extensions says about an installed plugin (its state, the services a pending one waits for, ...), and where a pulled one came from. */
99
116
  export interface SharedPluginInfo extends ExtensionInfo {
@@ -101,6 +118,8 @@ export interface SharedPluginInfo extends ExtensionInfo {
101
118
  readonly pulledFrom?: string
102
119
  /** Who released it: the account of a `github:` source or of a release address on github.com. */
103
120
  readonly author?: string
121
+ /** The version its `package.json` names (`version` is the installed content's short digest); absent without one. */
122
+ readonly release?: string
104
123
  }
105
124
 
106
125
  /** What removing a plugin did with its folder in `plugins/`. */
@@ -130,6 +149,20 @@ declare module '@deepseek-ai/cordis' {
130
149
  const authorOf = (source: string): string | undefined =>
131
150
  (/^github:([^/]+)\//u.exec(source) ?? /^https:\/\/github\.com\/([^/]+)\//u.exec(source))?.[1]
132
151
 
152
+ /** The version a plugin folder's `package.json` names, when it names a plain one. */
153
+ function releaseOf(folder: string): string | undefined {
154
+ try {
155
+ const version = (
156
+ JSON.parse(readFileSync(join(folder, 'package.json'), 'utf8')) as { version?: unknown }
157
+ ).version
158
+ return typeof version === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/u.test(version)
159
+ ? version
160
+ : undefined
161
+ } catch {
162
+ return undefined
163
+ }
164
+ }
165
+
133
166
  /** The license a new public plugin is offered under; the plan shows it before the person says yes. */
134
167
  const DEFAULT_LICENSE = 'MIT'
135
168
  const LICENSE_ID = /^[A-Za-z0-9][A-Za-z0-9.+-]*$/u
@@ -176,6 +209,7 @@ export class PluginShare extends Service {
176
209
  readonly restored: Promise<readonly RestoreOutcome[]>
177
210
  private readonly dir: string
178
211
  private readonly releases: ReleasePort
212
+ private readonly download: (url: string) => Promise<Uint8Array>
179
213
  private readonly ports: PublishPorts
180
214
  /** One at a time: two pulls of one name must not race for its folder, nor two publishes for its package. */
181
215
  private queue: Promise<unknown> = Promise.resolve()
@@ -193,6 +227,7 @@ export class PluginShare extends Service {
193
227
  this.own = ctx
194
228
  this.dir = resolve(config.dir ?? join(ctx.appInstance.root, 'plugins'))
195
229
  this.releases = config.releases ?? githubReleasesOverHttps()
230
+ this.download = config.download ?? httpsDownload()
196
231
  this.ports = config.publish ?? { host: githubCli(), git: gitCli() }
197
232
  // Removing a plugin removes its record. Listened to here, not asked of each caller: every way of removing it goes through extensions.
198
233
  ctx.on('extensions/changed', (name, change) => {
@@ -209,6 +244,67 @@ export class PluginShare extends Service {
209
244
  return this.inTurn(() => this.pullNow(source))
210
245
  }
211
246
 
247
+ /**
248
+ * Pull every source of a list, a file in the app or an https address (a gist, a file in the group's repository): one after the other and each on
249
+ * its own, so a source that is refused, or clashes with a plugin already here, is reported with its reason while the others install and keep running.
250
+ * A list that cannot be read, or has a line that is not one source, pulls nothing.
251
+ */
252
+ async pullList(from: string): Promise<PullListResult> {
253
+ const text = await this.readList(from)
254
+ if (typeof text !== 'string') return text
255
+ const { sources, problems } = parsePullList(text)
256
+ if (problems.length > 0)
257
+ return refuse('list', `${from}: ${problems.join('; ')}`, 'fix the list; nothing was pulled')
258
+ if (sources.length === 0)
259
+ return refuse(
260
+ 'list',
261
+ `${from} names no plugin`,
262
+ 'write one source per line, e.g. github:someone/pomodoro@0.1.0',
263
+ )
264
+ const entries: PullListEntry[] = []
265
+ for (const { line, source } of sources)
266
+ entries.push({ line, source, result: await this.pull(source) })
267
+ return { ok: true, from, entries }
268
+ }
269
+
270
+ /** The text of a list, or why it cannot be read. */
271
+ private async readList(from: string): Promise<string | PullRefused> {
272
+ const where = pullListLocation(from)
273
+ if (where === undefined)
274
+ return refuse(
275
+ 'list',
276
+ `"${from}" is not a list`,
277
+ 'name a file in the app (e.g. group.txt) or an https address of one',
278
+ )
279
+ try {
280
+ let bytes: Uint8Array
281
+ if (where.kind === 'address') bytes = await this.download(where.url)
282
+ else {
283
+ const path = resolve(this.own.appInstance.root, where.path)
284
+ if (!existsSync(path) || !statSync(path).isFile())
285
+ return refuse(
286
+ 'list',
287
+ `there is no file ${where.path}`,
288
+ 'name a file in the app, or an https address',
289
+ )
290
+ bytes = readFileSync(path)
291
+ }
292
+ if (bytes.length > LIST_MAX_BYTES)
293
+ return refuse(
294
+ 'list',
295
+ `${from} is larger than ${String(LIST_MAX_BYTES)} bytes, so it is not a list of plugins`,
296
+ 'name the list itself',
297
+ )
298
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
299
+ } catch (cause) {
300
+ return refuse(
301
+ 'list',
302
+ `${from} could not be read: ${reasonOf(cause)}`,
303
+ 'check the address; nothing was pulled',
304
+ )
305
+ }
306
+ }
307
+
212
308
  /**
213
309
  * The content digest of a pulled plugin as it was installed: set before its folder appears, so whoever sees the folder change can tell the release as
214
310
  * pulled from an edit (the frame then does not load the app again for it). A pull from a local file has no record in `blend.yaml`; this knows it too.
@@ -224,10 +320,12 @@ export class PluginShare extends Service {
224
320
  return this.own.extensions.list().map((info) => {
225
321
  const record = records[info.name]
226
322
  const author = record === undefined ? undefined : authorOf(record.source)
323
+ const release = releaseOf(join(this.dir, info.name))
227
324
  return {
228
325
  ...info,
229
326
  ...(record === undefined ? {} : { pulledFrom: record.source }),
230
327
  ...(author === undefined ? {} : { author }),
328
+ ...(release === undefined ? {} : { release }),
231
329
  }
232
330
  })
233
331
  }
@@ -457,7 +555,7 @@ export class PluginShare extends Service {
457
555
  {
458
556
  store: new PluginStore(scratch),
459
557
  releases: this.releases,
460
- download: httpsDownload(),
558
+ download: this.download,
461
559
  },
462
560
  expectIntegrity === undefined ? {} : { expectIntegrity },
463
561
  )
@@ -58,8 +58,22 @@ export interface PublishPorts {
58
58
  export const pullLineOf = (owner: string, repo: string, version: string, digest: string): string =>
59
59
  `npm run acryl -- plugin pull github:${owner}/${repo}@${version}@sha256:${digest}`
60
60
 
61
- /** The same plan: what the person confirmed is what would be published now. `released` only explains the version, and is gone once the next one is packaged. */
61
+ /**
62
+ * The same plan: what the person confirmed is what would be published now. `released` only explains the version, and is gone once the next one is packaged.
63
+ * Field by field, so a plan that came back through JSON from another surface (the frame's confirm) is the same plan whatever order its fields arrived in.
64
+ */
62
65
  export function samePlan(a: PublishPlan, b: PublishPlan): boolean {
63
- const compared = ({ released: _released, ...rest }: PublishPlan) => JSON.stringify(rest)
64
- return compared(a) === compared(b)
66
+ return (
67
+ a.owner === b.owner &&
68
+ a.repo === b.repo &&
69
+ a.version === b.version &&
70
+ a.visibility === b.visibility &&
71
+ a.license === b.license &&
72
+ a.createsRepository === b.createsRepository &&
73
+ a.integrity === b.integrity &&
74
+ a.digest === b.digest &&
75
+ a.pullLine === b.pullLine &&
76
+ a.files.length === b.files.length &&
77
+ a.files.every((file, index) => file === b.files[index])
78
+ )
65
79
  }
@@ -17,6 +17,8 @@ export type Refusal =
17
17
  | 'missing-service'
18
18
  | 'folder-exists'
19
19
  | 'install'
20
+ /** A list of sources that cannot be read, or holds a line that is not one source. */
21
+ | 'list'
20
22
 
21
23
  export interface PullRefused {
22
24
  readonly ok: false
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A group's list of plugins: one source per line, as each person sends it (`github:<owner>/<repo>@<version>@sha256:<digest>`, or the whole line
3
+ * `plugin publish` prints). The list is text a person or a group keeps (a file in the app, a gist, a file in a repository), so reading it is
4
+ * forgiving about comments, blank lines and the pasted command, and strict about anything else: a line that is not one source is named, not guessed.
5
+ */
6
+
7
+ /** A list is a few lines of text: anything larger is not a list of plugins. */
8
+ export const LIST_MAX_BYTES = 64 * 1024
9
+
10
+ /** More sources than a group has people is a mistake, not a workshop. */
11
+ export const LIST_MAX_SOURCES = 100
12
+
13
+ /** The command a person pastes from `plugin publish`: only its source is kept. */
14
+ const PULL_COMMAND = /^(?:npm run acryl --\s+)?plugin pull\s+/u
15
+
16
+ /** A `#` at the start of a line or after a space begins a comment; a source never holds one there. */
17
+ const COMMENT = /(?:^|\s)#.*$/u
18
+
19
+ export interface PullListLine {
20
+ /** Its line in the list, from 1, so a problem is easy to find. */
21
+ readonly line: number
22
+ readonly source: string
23
+ }
24
+
25
+ export interface ParsedPullList {
26
+ /** The sources in their order, each once. */
27
+ readonly sources: readonly PullListLine[]
28
+ /** Lines that are not one source, each with what is wrong. */
29
+ readonly problems: readonly string[]
30
+ }
31
+
32
+ /** The sources of a list, in order and without repeats, and the lines that are not a source. */
33
+ export function parsePullList(text: string): ParsedPullList {
34
+ const sources: PullListLine[] = []
35
+ const problems: string[] = []
36
+ const seen = new Set<string>()
37
+ text.split(/\r?\n/u).forEach((raw, index) => {
38
+ const line = index + 1
39
+ const value = raw.replace(COMMENT, '').trim().replace(PULL_COMMAND, '').trim()
40
+ if (value === '') return
41
+ if (/\s/u.test(value)) {
42
+ problems.push(
43
+ `line ${String(line)} holds more than one word ("${value}"); write one source per line`,
44
+ )
45
+ return
46
+ }
47
+ if (seen.has(value)) return
48
+ seen.add(value)
49
+ sources.push({ line, source: value })
50
+ })
51
+ if (sources.length > LIST_MAX_SOURCES)
52
+ problems.push(
53
+ `the list has ${String(sources.length)} sources; a list holds at most ${String(LIST_MAX_SOURCES)}`,
54
+ )
55
+ return { sources, problems }
56
+ }
57
+
58
+ /** Where a list is read from: an https address, or a file (relative to the app folder). */
59
+ export type PullListLocation =
60
+ | { readonly kind: 'address'; readonly url: string }
61
+ | { readonly kind: 'file'; readonly path: string }
62
+
63
+ /** @returns undefined for something that is neither (an http: address, a github: source): the caller names what a list may be. */
64
+ export function pullListLocation(from: string): PullListLocation | undefined {
65
+ const value = from.trim()
66
+ if (value === '') return undefined
67
+ if (/^https:\/\//iu.test(value)) return { kind: 'address', url: value }
68
+ // A drive letter (C:\...) is a path; any other scheme is not a place a list is read from.
69
+ if (/^[a-z][a-z0-9+.-]+:/iu.test(value) && !/^[a-z]:[\\/]/iu.test(value)) return undefined
70
+ return { kind: 'file', path: value }
71
+ }