@standardagents/code-plugin-sdk 1.0.0-alpha.0 → 1.0.0-alpha.1

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/README.md CHANGED
@@ -54,6 +54,89 @@ Plugins retain Node access to files, networking and subprocesses under the user'
54
54
  Worker threads provide JavaScript fault isolation.
55
55
  Memory exhaustion outside V8 limits and native crashes can affect the runner process.
56
56
 
57
+ ## Collections
58
+
59
+ A plugin installs from a Git repository or an npm package. One source holds
60
+ one plugin or a collection of plugins.
61
+
62
+ A collection lists its plugins in `standard-plugins.json` at the root of the
63
+ repository or package:
64
+
65
+ ```json
66
+ {
67
+ "schema": 1,
68
+ "plugins": [
69
+ { "id": "builds-monitor", "path": "builds-monitor" },
70
+ { "id": "pong", "path": "games/pong" }
71
+ ]
72
+ }
73
+ ```
74
+
75
+ `schema` is `1`. `plugins` holds 1 to 256 entries. Each `id` follows the
76
+ plugin id pattern of the manifest and equals the `standardPlugin.id` in that
77
+ entry's `package.json`. Each `path` is a relative POSIX path of at most 512
78
+ characters with no leading slash, no backslash, and no empty, `.`, or `..`
79
+ segment. Ids and paths are unique within one collection. An installer
80
+ selects one entry with `--plugin <id>`.
81
+
82
+ A root without `standard-plugins.json` is a single plugin when its
83
+ `package.json` carries a valid `standardPlugin` manifest. Discovery treats it
84
+ as one entry at path `""`.
85
+
86
+ `validateCollection(value)` checks a parsed collection file and returns a
87
+ frozen `PluginCollection`. `resolveCollection({ collection, packageJson })`
88
+ applies the single-plugin fallback. `COLLECTION_FILE` names the file.
89
+
90
+ ## Dependencies
91
+
92
+ A plugin with `dependencies` or `optionalDependencies` ships a lockfile
93
+ beside its `package.json`, so every machine installs the same dependency
94
+ tree. The installer refuses a plugin with dependencies and no lockfile.
95
+
96
+ For an npm package the lockfile is `npm-shrinkwrap.json`. npm never
97
+ publishes `package-lock.json`; `npm shrinkwrap` converts an existing
98
+ `package-lock.json` into `npm-shrinkwrap.json`, and npm includes that file
99
+ when it publishes the package. For a Git repository either
100
+ `package-lock.json` or `npm-shrinkwrap.json` satisfies the rule.
101
+
102
+ The SDK belongs under `peerDependencies`, with a `devDependencies` copy for
103
+ the plugin's own tests. Inside Standard Code the bundled runtime supplies it.
104
+
105
+ `lockfileRequirement({ packageJson, sourceKind })` returns whether the rule
106
+ applies and which lockfile names satisfy it for `"npm"` or `"git"`.
107
+ `checkPackageForPublish({ packageJson, files })` reports problems as
108
+ `{ code, message }` objects: a missing lockfile, the SDK under
109
+ `dependencies`, a missing peer declaration, a missing or invalid manifest, an
110
+ entry outside the package files, or an id that differs from the collection
111
+ entry. Both helpers are pure; they read no files.
112
+
113
+ In a collection, each plugin directory holds its own `package.json` and its
114
+ own lockfile.
115
+
116
+ ## Publishing a plugin
117
+
118
+ The package installs a `standard-plugin` command for authoring.
119
+
120
+ ```sh
121
+ npx standard-plugin check
122
+ npx standard-plugin pack
123
+ npm publish
124
+ ```
125
+
126
+ `check [dir] [--source npm|git]` reads `package.json`, the optional
127
+ `standard-plugins.json`, every collection entry's `package.json`, and the
128
+ lockfiles. It prints each problem as `<path>: <code>: <message>` and exits
129
+ with status 1 when it finds one. `--source git` applies the Git lockfile
130
+ rule; the default is `npm`.
131
+
132
+ `pack [dir]` runs `check`. When a plugin has dependencies and no
133
+ `npm-shrinkwrap.json`, it runs `npm shrinkwrap`, which converts
134
+ `package-lock.json`, and asks you to commit the new file. It then runs
135
+ `npm pack --dry-run` and prints the files that npm will publish.
136
+
137
+ The command is authoring tooling. The SDK entry never imports it, and a
138
+ plugin never depends on it at run time.
139
+
57
140
  ## Testing
58
141
 
59
142
  `@standardagents/code-plugin-sdk/testing` exports `createHarness`.
@@ -66,7 +149,8 @@ The harness starts no subprocesses.
66
149
  ## Supported interface
67
150
 
68
151
  The public interface is the package's default export, its `testing` export,
69
- and the declarations in `src/index.d.ts` and `src/testing.d.ts`.
152
+ the declarations in `src/index.d.ts` and `src/testing.d.ts`, and the
153
+ `standard-plugin` command's `check` and `pack` behavior.
70
154
 
71
155
  The harness records the frames that the SDK runtime exchanges with its host,
72
156
  and `harness.receive` accepts such a frame. That wire protocol between the
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ // Authoring tool for plugin packages. The SDK entry never imports this file.
3
+ import { spawnSync } from 'node:child_process'
4
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
5
+ import { join, resolve } from 'node:path'
6
+ import { COLLECTION_FILE, PluginError, checkPackageForPublish, validateCollection } from '../src/index.mjs'
7
+
8
+ const USAGE = `usage: standard-plugin check [dir] [--source npm|git]
9
+ standard-plugin pack [dir]
10
+
11
+ check validates package.json, ${COLLECTION_FILE}, each collection entry, and lockfiles
12
+ pack runs check, creates npm-shrinkwrap.json when dependencies need one, then runs npm pack --dry-run`
13
+ const IGNORED_DIRECTORIES = new Set(['node_modules', '.git'])
14
+ const FILE_LIMIT = 50000
15
+
16
+ function readJson(path) {
17
+ let text
18
+ try { text = readFileSync(path, 'utf8') } catch (error) {
19
+ if (error.code === 'ENOENT') return undefined
20
+ throw error
21
+ }
22
+ return JSON.parse(text)
23
+ }
24
+
25
+ /** Relative POSIX paths of regular files under root, without following links. */
26
+ function listFiles(root) {
27
+ const files = []
28
+ const pending = ['']
29
+ while (pending.length) {
30
+ const directory = pending.pop()
31
+ for (const entry of readdirSync(join(root, directory), { withFileTypes: true })) {
32
+ const path = directory ? `${directory}/${entry.name}` : entry.name
33
+ if (entry.isDirectory()) { if (!IGNORED_DIRECTORIES.has(entry.name)) pending.push(path) }
34
+ else if (entry.isFile()) files.push(path)
35
+ if (files.length > FILE_LIMIT) throw new Error(`More than ${FILE_LIMIT} files under ${root}`)
36
+ }
37
+ }
38
+ return files
39
+ }
40
+
41
+ function tryJson(root, name, problems, location) {
42
+ try { return readJson(join(root, name)) } catch (error) {
43
+ problems.push({ location, code: 'invalid_json', message: `${name}: ${error.message}` })
44
+ return undefined
45
+ }
46
+ }
47
+
48
+ function collect(problems, location, list) {
49
+ for (const problem of list) problems.push({ location, ...problem })
50
+ }
51
+
52
+ /** Checks one source directory. Returns problems with the directory each one belongs to. */
53
+ function checkDirectory(root, { sourceKind = 'npm' } = {}) {
54
+ const problems = []
55
+ const files = listFiles(root)
56
+ const packageJson = tryJson(root, 'package.json', problems, '.')
57
+ const collectionInput = tryJson(root, COLLECTION_FILE, problems, '.')
58
+ if (collectionInput === undefined) {
59
+ if (packageJson === undefined) problems.push({ location: '.', code: 'package_missing', message: `package.json or ${COLLECTION_FILE} is required` })
60
+ else collect(problems, '.', checkPackageForPublish({ packageJson, files, sourceKind }))
61
+ return problems
62
+ }
63
+ let collection
64
+ try { collection = validateCollection(collectionInput) } catch (error) {
65
+ if (!(error instanceof PluginError)) throw error
66
+ problems.push({ location: '.', code: error.code, message: `${COLLECTION_FILE}: ${error.message}` })
67
+ return problems
68
+ }
69
+ if (packageJson !== undefined) collect(problems, '.', checkPackageForPublish({ packageJson, files, sourceKind, requireManifest: false }))
70
+ else if (sourceKind === 'npm') problems.push({ location: '.', code: 'package_missing', message: 'an npm package needs a root package.json' })
71
+ for (const entry of collection.plugins) {
72
+ const entryPackage = tryJson(join(root, entry.path), 'package.json', problems, entry.path)
73
+ if (entryPackage === undefined) {
74
+ if (!problems.some(problem => problem.location === entry.path)) {
75
+ problems.push({ location: entry.path, code: 'package_missing', message: `collection entry ${entry.id} has no package.json` })
76
+ }
77
+ continue
78
+ }
79
+ const prefix = `${entry.path}/`
80
+ const entryFiles = files.filter(file => file.startsWith(prefix)).map(file => file.slice(prefix.length))
81
+ collect(problems, entry.path, checkPackageForPublish({ packageJson: entryPackage, files: entryFiles, sourceKind, expectedId: entry.id }))
82
+ }
83
+ return problems
84
+ }
85
+
86
+ function report(problems) {
87
+ for (const problem of problems) console.log(`${problem.location}: ${problem.code}: ${problem.message}`)
88
+ console.log(problems.length ? `${problems.length} problem${problems.length === 1 ? '' : 's'}` : 'ok')
89
+ }
90
+
91
+ function npm(args, cwd) {
92
+ const result = spawnSync('npm', args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' })
93
+ if (result.error) throw result.error
94
+ return result.status ?? 1
95
+ }
96
+
97
+ function parse(argv) {
98
+ const [command, ...rest] = argv
99
+ const options = { directory: '.', sourceKind: 'npm' }
100
+ for (let index = 0; index < rest.length; index++) {
101
+ const argument = rest[index]
102
+ if (argument === '--source') {
103
+ options.sourceKind = rest[++index]
104
+ if (!['npm', 'git'].includes(options.sourceKind)) throw new Error('--source takes npm or git')
105
+ } else if (argument.startsWith('-')) throw new Error(`unknown option ${argument}`)
106
+ else options.directory = argument
107
+ }
108
+ return { command, ...options, directory: resolve(options.directory) }
109
+ }
110
+
111
+ function check(options) {
112
+ const problems = checkDirectory(options.directory, options)
113
+ report(problems)
114
+ return problems.length ? 1 : 0
115
+ }
116
+
117
+ function pack(options) {
118
+ if (options.sourceKind !== 'npm') throw new Error('pack checks an npm package; --source git does not apply')
119
+ let problems = checkDirectory(options.directory, options)
120
+ const missing = problems.filter(problem => problem.code === 'lockfile_missing')
121
+ for (const problem of missing) {
122
+ const directory = resolve(options.directory, problem.location)
123
+ if (!existsSync(join(directory, 'package-lock.json'))) {
124
+ console.log(`${problem.location}: run npm install first so npm shrinkwrap has a package-lock.json to convert`)
125
+ continue
126
+ }
127
+ console.log(`${problem.location}: creating npm-shrinkwrap.json from package-lock.json`)
128
+ const status = npm(['shrinkwrap'], directory)
129
+ if (status !== 0) return status
130
+ console.log(`${problem.location}: commit npm-shrinkwrap.json; npm publishes it with the package`)
131
+ }
132
+ if (missing.length) problems = checkDirectory(options.directory, options)
133
+ report(problems)
134
+ if (problems.length) return 1
135
+ return npm(['pack', '--dry-run'], options.directory)
136
+ }
137
+
138
+ function main(argv) {
139
+ let options
140
+ try { options = parse(argv) } catch (error) { console.error(error.message); console.error(USAGE); return 2 }
141
+ try {
142
+ if (options.command === 'check') return check(options)
143
+ if (options.command === 'pack') return pack(options)
144
+ } catch (error) { console.error(`standard-plugin: ${error.message}`); return 2 }
145
+ console.error(USAGE)
146
+ return 2
147
+ }
148
+
149
+ process.exitCode = main(process.argv.slice(2))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@standardagents/code-plugin-sdk",
3
- "version": "1.0.0-alpha.0",
3
+ "version": "1.0.0-alpha.1",
4
4
  "type": "module",
5
5
  "description": "Standard Code plugin authoring SDK",
6
6
  "license": "MIT",
@@ -15,6 +15,7 @@
15
15
  ".": { "types": "./src/index.d.ts", "import": "./src/index.mjs" },
16
16
  "./testing": { "types": "./src/testing.d.ts", "import": "./src/testing.mjs" }
17
17
  },
18
- "files": ["src", "README.md", "LICENSE"],
18
+ "bin": { "standard-plugin": "./bin/standard-plugin.mjs" },
19
+ "files": ["src", "bin", "README.md", "LICENSE"],
19
20
  "scripts": { "test": "node --test test/*.test.mjs" }
20
21
  }
Binary file
package/src/index.d.ts CHANGED
@@ -262,3 +262,49 @@ export interface PluginDefinition {
262
262
  export function definePlugin(definition: PluginDefinition): Readonly<PluginDefinition>;
263
263
  export function validateManifest(value: unknown): Readonly<PluginManifest>;
264
264
  export class PluginError extends Error { code: string; constructor(code: string, message: string) }
265
+
266
+ /** One plugin inside a collection. The path is relative to the source root. */
267
+ export interface PluginCollectionEntry {
268
+ id: string;
269
+ /** Relative POSIX path; "" names the source root for a single-plugin source. */
270
+ path: string;
271
+ }
272
+ /** The standard-plugins.json file at the root of a repository or npm package. */
273
+ export interface PluginCollection {
274
+ schema: 1;
275
+ plugins: PluginCollectionEntry[];
276
+ }
277
+ /** File name of the collection manifest: standard-plugins.json. */
278
+ export const COLLECTION_FILE: 'standard-plugins.json';
279
+ export function validateCollection(value: unknown): Readonly<PluginCollection>;
280
+ /** A collection file wins; without one, a valid package manifest makes the root one plugin at path "". */
281
+ export function resolveCollection(source: { collection?: unknown; packageJson?: unknown }): Readonly<PluginCollection>;
282
+
283
+ export type SourceKind = 'git' | 'npm';
284
+ export type LockfileName = 'package-lock.json' | 'npm-shrinkwrap.json';
285
+ export interface LockfileRequirement {
286
+ /** True when the package declares dependencies or optionalDependencies. */
287
+ required: boolean;
288
+ dependencies: readonly string[];
289
+ /** Lockfile names that satisfy the rule for this source kind. */
290
+ lockfiles: readonly LockfileName[];
291
+ }
292
+ /** Package name of this SDK, which a plugin lists under peerDependencies. */
293
+ export const SDK_PACKAGE: '@standardagents/code-plugin-sdk';
294
+ export function lockfileRequirement(input: { packageJson: unknown; sourceKind: SourceKind }): LockfileRequirement;
295
+ export interface PublishProblem {
296
+ code: 'invalid_package' | 'package_private' | 'lockfile_missing' | 'sdk_dependency' | 'sdk_peer_missing' |
297
+ 'manifest_missing' | 'invalid_manifest' | 'invalid_payload' | 'payload_too_large' | 'incompatible_version' |
298
+ 'entry_missing' | 'id_mismatch';
299
+ message: string;
300
+ }
301
+ /** Pure checks over a package.json and the relative paths of the files that ship with it. */
302
+ export function checkPackageForPublish(input: {
303
+ packageJson: unknown;
304
+ files: readonly string[];
305
+ sourceKind?: SourceKind;
306
+ /** False for a collection root whose package.json carries no plugin manifest. */
307
+ requireManifest?: boolean;
308
+ /** The collection entry id the manifest must match. */
309
+ expectedId?: string;
310
+ }): readonly PublishProblem[];
package/src/index.mjs CHANGED
@@ -1,6 +1,10 @@
1
1
  import { ensure, identifier, validateManifest, PluginError } from './manifest.mjs'
2
+ import { COLLECTION_FILE, resolveCollection, validateCollection } from './collection.mjs'
3
+ import { SDK_PACKAGE, checkPackageForPublish, lockfileRequirement } from './publish.mjs'
2
4
 
3
5
  export { validateManifest, PluginError }
6
+ export { COLLECTION_FILE, resolveCollection, validateCollection }
7
+ export { SDK_PACKAGE, checkPackageForPublish, lockfileRequirement }
4
8
 
5
9
  export function definePlugin(definition) {
6
10
  ensure(definition && identifier(definition.id) && typeof definition.activate === 'function',
@@ -0,0 +1,62 @@
1
+ import { PluginError, ensure, object, validateManifest } from './manifest.mjs'
2
+
3
+ export const SDK_PACKAGE = '@standardagents/code-plugin-sdk'
4
+ export const SOURCE_KINDS = Object.freeze(['git', 'npm'])
5
+ const LOCKFILES = Object.freeze({ git: Object.freeze(['package-lock.json', 'npm-shrinkwrap.json']), npm: Object.freeze(['npm-shrinkwrap.json']) })
6
+
7
+ function names(section) { return object(section) ? Object.keys(section) : [] }
8
+
9
+ /** Runtime dependencies are the ones npm installs for a consumer of the package. */
10
+ export function runtimeDependencies(packageJson) {
11
+ ensure(object(packageJson), 'invalid_package', 'package.json must contain an object')
12
+ return [...new Set([...names(packageJson.dependencies), ...names(packageJson.optionalDependencies)])]
13
+ }
14
+
15
+ /**
16
+ * A plugin with runtime dependencies ships a lockfile. npm publishes
17
+ * npm-shrinkwrap.json and drops package-lock.json, so an npm source needs the
18
+ * shrinkwrap; a Git source may keep either file.
19
+ */
20
+ export function lockfileRequirement({ packageJson, sourceKind }) {
21
+ ensure(SOURCE_KINDS.includes(sourceKind), 'invalid_source', 'Source kind must be git or npm')
22
+ const dependencies = runtimeDependencies(packageJson)
23
+ return Object.freeze({ required: dependencies.length > 0, dependencies: Object.freeze(dependencies), lockfiles: LOCKFILES[sourceKind] })
24
+ }
25
+
26
+ function normalize(path) { return path.replace(/^\.\//, '') }
27
+
28
+ /**
29
+ * Pure publish checks for one plugin directory. `files` lists the relative
30
+ * POSIX paths that ship with the package. Returns a list of problems; an empty
31
+ * list means the package passes.
32
+ */
33
+ export function checkPackageForPublish({ packageJson, files, sourceKind = 'npm', requireManifest = true, expectedId } = {}) {
34
+ ensure(Array.isArray(files) && files.every(file => typeof file === 'string'), 'invalid_source', 'files must list relative paths')
35
+ const problems = []
36
+ const problem = (code, message) => problems.push(Object.freeze({ code, message }))
37
+ if (!object(packageJson)) return Object.freeze([Object.freeze({ code: 'invalid_package', message: 'package.json must contain an object' })])
38
+ const present = new Set(files.map(normalize))
39
+ if (packageJson.private === true && sourceKind === 'npm') problem('package_private', 'package.json marks the package private, so npm refuses to publish it')
40
+ const requirement = lockfileRequirement({ packageJson, sourceKind })
41
+ if (requirement.required && !requirement.lockfiles.some(name => present.has(name))) {
42
+ problem('lockfile_missing', `Dependencies (${requirement.dependencies.join(', ')}) need ${requirement.lockfiles.join(' or ')} beside package.json`)
43
+ }
44
+ for (const section of ['dependencies', 'optionalDependencies']) {
45
+ if (names(packageJson[section]).includes(SDK_PACKAGE)) problem('sdk_dependency', `${SDK_PACKAGE} belongs under peerDependencies, not ${section}`)
46
+ }
47
+ let manifest = null
48
+ if (packageJson.standardPlugin === undefined) {
49
+ if (requireManifest) problem('manifest_missing', 'package.json has no standardPlugin manifest')
50
+ } else {
51
+ try { manifest = validateManifest(packageJson.standardPlugin) } catch (error) {
52
+ if (!(error instanceof PluginError)) throw error
53
+ problem(error.code, error.message)
54
+ }
55
+ }
56
+ if (manifest) {
57
+ if (!names(packageJson.peerDependencies).includes(SDK_PACKAGE)) problem('sdk_peer_missing', `${SDK_PACKAGE} must be listed under peerDependencies`)
58
+ if (!present.has(normalize(manifest.entry))) problem('entry_missing', `Entry ${manifest.entry} is not among the package files`)
59
+ if (expectedId !== undefined && manifest.id !== expectedId) problem('id_mismatch', `Manifest id ${manifest.id} differs from collection entry ${expectedId}`)
60
+ }
61
+ return Object.freeze(problems)
62
+ }