@biffo/cli 0.199.0 → 0.199.2

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.
@@ -55,9 +55,36 @@ engine** (`services/_plugins/orchestrator/` in the biffo-template monorepo) —
55
55
  class, same layout, and it ships a real `terraform/` too. It has considerably
56
56
  more moving parts (workflow definitions, actions, SigV4 calls into the Core
57
57
  API); this template's `example_plugin` is deliberately the smallest version of
58
- the same shape: one table (`example_widgets`), four routes, one `on_install`
58
+ the same shape: one table (`example_widgets`), four routes, one idempotent
59
59
  seed, one `@subscribe` handler.
60
60
 
61
+ ### Seeding: not from `on_install()`
62
+
63
+ `BiffoPluginBase` requires `on_install()`, `on_uninstall()` and `on_upgrade()`,
64
+ and they are **not invoked** — ADR-0003 §9 described a `biffo plugin install`
65
+ that would call them and the call site was never built. Define them as no-ops,
66
+ as `plugin.py` does. An earlier revision of this skeleton seeded through
67
+ `on_install()`; anyone who copied it got a seed that silently never ran, the
68
+ plugin deploying clean with empty tables and the symptom surfacing wherever
69
+ those rows were read
70
+ ([#709](https://github.com/keiranholloway/biffo-template/issues/709)).
71
+
72
+ Baseline data goes in one of two places instead, and `seed_default_widget()` in
73
+ `plugin.py` documents both at the call site:
74
+
75
+ - **If your plugin declares an `api_ingress` ASGI app** (ADR-0021), seed from
76
+ that app's lifespan. The shared plugin host runs each mounted app's lifespan
77
+ itself, because Starlette's `Mount` never delivers the lifespan scope — until
78
+ [#948](https://github.com/keiranholloway/biffo-template/pull/948) a plugin's
79
+ own `@app.on_event("startup")` was equally dead. It fires on every cold start,
80
+ so the seed must be idempotent; Core's own
81
+ `POST /api/v1/internal/plugins/me/config/seed` was not idempotent until
82
+ [#1000](https://github.com/keiranholloway/biffo-template/pull/1000), so treat
83
+ this route as new and check your own.
84
+ - **If it doesn't** — like `example_plugin`, which is event-only — there is no
85
+ startup to hang anything on. Put the rows in a SQL module in the instance's
86
+ `db/imports/<name>/`, applied by `biffo data apply` on every deploy.
87
+
61
88
  > Earlier revisions of this README pointed at an RBAC reference plugin at
62
89
  > `services/rbac/`. That plugin was removed by
63
90
  > [ADR-0011](https://github.com/keiranholloway/biffo-template/blob/main/docs/ADR/0011-authorization-is-a-core-concern.md)
@@ -24,7 +24,7 @@
24
24
  "name": "is_active",
25
25
  "type": "Boolean",
26
26
  "nullable": true,
27
- "description": "Nullable because declared column defaults are not applied by the generated migration DDL (only the in-process SQLAlchemy model) — see README's 'Known limitations' section. Treat NULL as false; this plugin's own on_install() always sets it explicitly."
27
+ "description": "Nullable because declared column defaults are not applied by the generated migration DDL (only the in-process SQLAlchemy model) — see README's 'Known limitations' section. Treat NULL as false; this plugin's own seed always sets it explicitly."
28
28
  }
29
29
  ],
30
30
  "indexes": [
@@ -1,11 +1,17 @@
1
1
  """ExamplePlugin — this plugin's BiffoPluginBase implementation.
2
2
 
3
- Seeds a single default widget on install and reacts to
4
- `biffo.core/UserCreated` by logging the new user. Replace both with real
5
- logic for your own plugin — this exists to show a working, correctly-wired
6
- skeleton: constructor -> manifest loading -> `self.api` -> `@self.subscribe`
7
- -> `on_install`/`on_uninstall`, exercised end to end by the tests in
8
- `tests/test_example_plugin.py`.
3
+ Reacts to `biffo.core/UserCreated` by logging the new user, and carries an
4
+ idempotent seed for its baseline row. Replace both with real logic for your
5
+ own plugin — this exists to show a working, correctly-wired skeleton:
6
+ constructor -> manifest loading -> `self.api` -> `@self.subscribe`, exercised
7
+ end to end by the tests in `tests/test_example_plugin.py`.
8
+
9
+ **`on_install()` / `on_uninstall()` are not invoked.** They are required by
10
+ `BiffoPluginBase` and nothing calls them, so they are no-ops here and should
11
+ be no-ops in your plugin (biffo-template#709). An earlier version of this
12
+ skeleton seeded through `on_install()` — copying that gives you a seed that
13
+ silently never runs. `seed_default_widget()` below shows where seeding
14
+ actually goes.
9
15
 
10
16
  Modelled directly on the RBAC reference plugin's
11
17
  `services/rbac/src/rbac/plugin.py` (PR #76) in the biffo-template monorepo —
@@ -15,7 +21,6 @@ tables, an event-driven side effect, and an in-process (non-CRUD) helper.
15
21
 
16
22
  from __future__ import annotations
17
23
 
18
- import asyncio
19
24
  from typing import Any
20
25
 
21
26
  from aws_lambda_powertools import Logger
@@ -27,11 +32,11 @@ logger = Logger()
27
32
 
28
33
  _PLUGIN_BASE_PATH = "/api/v1/plugins/example-plugin"
29
34
 
30
- # Seeded by on_install(). Real plugins would seed whatever baseline data
31
- # their feature needs (RBAC's plugin.py seeds three roles, for example).
35
+ # Seeded by seed_default_widget(). Real plugins would seed whatever baseline
36
+ # data their feature needs.
32
37
  _DEFAULT_WIDGET: dict[str, Any] = {
33
38
  "name": "starter-widget",
34
- "description": "Created automatically by on_install(). Safe to delete.",
39
+ "description": "Created by this plugin's own seed. Safe to delete.",
35
40
  "is_active": True,
36
41
  }
37
42
 
@@ -48,23 +53,50 @@ class ExamplePlugin(BiffoPluginBase):
48
53
  await self._log_user_created(event)
49
54
 
50
55
  def on_install(self) -> None:
51
- """Called by the CLI when the plugin is installed.
56
+ """No-op. **Not invoked** nothing calls it (biffo-template#709).
52
57
 
53
- `BiffoPluginBase.on_install` is declared synchronous but
54
- `BiffoAPIClient` is entirely async (same constraint the RBAC
55
- reference plugin documents) bridge with `asyncio.run` here and
56
- keep the actual async logic in the public `seed_default_widget()`,
57
- which tests and any future async caller can `await` directly.
58
+ `BiffoPluginBase` requires it, so it is defined; `biffo plugin
59
+ install` does not run it, and neither does anything else. Put no
60
+ logic here. Seeding goes in `seed_default_widget()` below.
58
61
  """
59
- asyncio.run(self.seed_default_widget())
62
+ return None
60
63
 
61
64
  async def seed_default_widget(self) -> None:
62
- """The async core of on_install().
63
-
64
- Generic CRUD (issue #19) has no upsert every POST creates a new
65
- row so this lists existing widgets first and only creates the
66
- default one if it's missing by name, making repeated installs a
67
- no-op instead of creating duplicate rows.
65
+ """Create this plugin's baseline row, idempotently.
66
+
67
+ Nothing in this skeleton calls thisdeliberately. **Where you call
68
+ it from depends on what kind of plugin you are building**, and
69
+ neither answer is `on_install()`:
70
+
71
+ - **A plugin with an `api_ingress` ASGI app** (ADR-0021) seeds from
72
+ its app's *lifespan*, which the shared plugin host drives on the
73
+ first invocation of each process::
74
+
75
+ @asynccontextmanager
76
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
77
+ await plugin.seed_default_widget()
78
+ yield
79
+
80
+ app = FastAPI(lifespan=lifespan)
81
+
82
+ The host has to run that handshake itself, because Starlette's
83
+ `Mount` never delivers the lifespan scope to a mounted app — until
84
+ biffo-template#948 a plugin's own `@app.on_event("startup")` was as
85
+ dead as `on_install()`. It runs on every cold start, so the seed
86
+ must be idempotent (below), and Core's own seed endpoint
87
+ `POST /api/v1/internal/plugins/me/config/seed` was itself not
88
+ idempotent until #1000. Verify your seed; do not assume it.
89
+
90
+ - **An event-only plugin like this one** declares no `api_ingress`,
91
+ so it has no startup at all. Its baseline rows belong in a SQL
92
+ module in the *instance's* `db/imports/<name>/`, applied by
93
+ `biffo data apply` on every deploy — the mechanism the first-party
94
+ plugins use.
95
+
96
+ Idempotency: generic CRUD (issue #19) has no upsert — every POST
97
+ creates a new row — so this lists existing widgets first and only
98
+ creates the default one if it's missing by name, making a repeated
99
+ cold start a no-op instead of accumulating duplicates.
68
100
  """
69
101
  existing = await self.api.get(f"{_PLUGIN_BASE_PATH}/widgets")
70
102
  existing_names = {row.get("name") for row in existing}
@@ -72,13 +104,12 @@ class ExamplePlugin(BiffoPluginBase):
72
104
  await self.api.post(f"{_PLUGIN_BASE_PATH}/widgets", json=dict(_DEFAULT_WIDGET))
73
105
 
74
106
  def on_uninstall(self) -> None:
75
- """Called by the CLI when the plugin is uninstalled.
107
+ """No-op. **Not invoked** nothing calls it (biffo-template#709).
76
108
 
77
- No-op: dropping this plugin's tables is the CLI's job (ADR-0003
78
- section 9 `biffo plugin uninstall` applies a generated migration
79
- that drops them). Override this if your plugin needs additional
80
- cleanup beyond that (e.g. deleting objects it created outside its
81
- own tables).
109
+ There is no teardown moment for it to belong to either: `biffo plugin
110
+ uninstall` removes the plugin's code and Terraform and deliberately
111
+ leaves its tables in place (ADR-0003 section 9). Cleanup beyond that
112
+ is a hand-written Alembic migration, not a hook.
82
113
  """
83
114
  return None
84
115
 
@@ -1,5 +1,9 @@
1
- """Tests for ExamplePlugin: manifest loading, lifecycle hooks, and the
2
- UserCreated event subscription.
1
+ """Tests for ExamplePlugin: manifest loading, the seed, and the UserCreated
2
+ event subscription.
3
+
4
+ `on_install()` / `on_uninstall()` are asserted to be no-ops, not to seed:
5
+ they are **not invoked** by anything (biffo-template#709), so a test that
6
+ proved seeding through them would be proving something that never happens.
3
7
 
4
8
  Modelled on the RBAC reference plugin's
5
9
  `services/rbac/tests/test_rbac_plugin.py` (PR #76) in the biffo-template
@@ -48,7 +52,7 @@ class TestSubscription:
48
52
  assert plugin.events.has_subscription("UserCreated")
49
53
 
50
54
 
51
- class TestOnInstall:
55
+ class TestSeeding:
52
56
  async def test_seeds_default_widget(self) -> None:
53
57
  plugin, fake = _make_plugin()
54
58
 
@@ -73,22 +77,29 @@ class TestOnInstall:
73
77
  widget = fake.tables["widgets"][0]
74
78
  assert widget["is_active"] is True
75
79
 
80
+
81
+ class TestLifecycleHooksAreNoOps:
82
+ """The ABC's hooks are **not invoked** by anything (biffo-template#709).
83
+
84
+ So the contract worth asserting is that they do nothing — in particular
85
+ that neither of them seeds. A plugin whose baseline data depends on one
86
+ of these ships a seed that never runs.
87
+ """
88
+
89
+ def test_on_install_is_a_noop_and_seeds_nothing(self) -> None:
90
+ plugin, fake = _make_plugin()
91
+
92
+ assert plugin.on_install() is None
93
+ assert fake.tables["widgets"] == []
94
+
76
95
  def test_on_uninstall_is_a_noop(self) -> None:
77
96
  plugin, _ = _make_plugin()
78
97
  assert plugin.on_uninstall() is None
79
98
 
80
- def test_on_install_sync_bridge_seeds_widget(self) -> None:
81
- """The public, ABC-compliant on_install() entrypoint (a plain sync
82
- function calling asyncio.run(seed_default_widget()) internally) —
83
- exercised from a genuinely synchronous test function, the same kind
84
- of context the CLI would call it from, since asyncio.run() cannot be
85
- nested inside pytest-asyncio's already-running loop."""
99
+ def test_on_upgrade_is_a_noop(self) -> None:
86
100
  plugin, fake = _make_plugin()
87
-
88
- plugin.on_install()
89
-
90
- names = {r["name"] for r in fake.tables["widgets"]}
91
- assert names == {_DEFAULT_WIDGET["name"]}
101
+ assert plugin.on_upgrade("0.0.1") is None
102
+ assert fake.tables["widgets"] == []
92
103
 
93
104
 
94
105
  class TestUserCreatedLogsEvent:
package/dist/index.js CHANGED
@@ -216,6 +216,34 @@ function latestCoreVersionFromTags(repo, git = defaultTagRunner, options = {}) {
216
216
  var CORE_TAG_PREFIX = "core-v";
217
217
  var defaultTagRunner = (args) => execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
218
218
 
219
+ // src/lib/git-tracked-files.ts
220
+ import { execFileSync as execFileSync2 } from "child_process";
221
+ import { realpathSync } from "fs";
222
+ var defaultGit = (args) => execFileSync2("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
223
+ function gitTrackedFiles(root, git = defaultGit) {
224
+ let top;
225
+ try {
226
+ top = git(["-C", root, "rev-parse", "--show-toplevel"]).trim();
227
+ } catch {
228
+ return null;
229
+ }
230
+ if (!top) return null;
231
+ try {
232
+ if (realpathSync(top) !== realpathSync(root)) return null;
233
+ } catch {
234
+ return null;
235
+ }
236
+ let out;
237
+ try {
238
+ out = git(["-C", root, "ls-files", "-z"]);
239
+ } catch {
240
+ return null;
241
+ }
242
+ const files = out.split("\0").filter((p) => p !== "");
243
+ if (files.length === 0) return null;
244
+ return new Set(files);
245
+ }
246
+
219
247
  // src/lib/core-manifest.ts
220
248
  var CORE_MANIFEST_FILE = "core-manifest.json";
221
249
  var CoreManifestSchema2 = z2.object({
@@ -315,8 +343,9 @@ function isTemplateOwned(relPath, manifest) {
315
343
  function toPosix(p) {
316
344
  return sep === "/" ? p : p.split(sep).join("/");
317
345
  }
318
- function listTemplateOwnedFiles(root, manifest) {
346
+ function listTemplateOwnedFiles(root, manifest, options = {}) {
319
347
  const out = [];
348
+ const tracked = options.trackedOnly ? options.git ? gitTrackedFiles(root, options.git) : gitTrackedFiles(root) : null;
320
349
  function walk(dir) {
321
350
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
322
351
  if (entry.isDirectory() && HARD_EXCLUDED_DIRS.has(entry.name)) continue;
@@ -325,6 +354,7 @@ function listTemplateOwnedFiles(root, manifest) {
325
354
  walk(abs);
326
355
  } else if (entry.isFile()) {
327
356
  const rel = toPosix(relative(root, abs));
357
+ if (tracked && !tracked.has(rel)) continue;
328
358
  if (isTemplateOwned(rel, manifest)) out.push(rel);
329
359
  }
330
360
  }
@@ -335,10 +365,11 @@ function listTemplateOwnedFiles(root, manifest) {
335
365
  function sameFile(a, b) {
336
366
  return readFileSync2(a).equals(readFileSync2(b));
337
367
  }
338
- function computeCoreDiff(templateRoot, instanceRoot, manifest, baseRoot) {
339
- const templateFiles = new Set(listTemplateOwnedFiles(templateRoot, manifest));
368
+ function computeCoreDiff(templateRoot, instanceRoot, manifest, baseRoot, options = {}) {
369
+ const trackedOnly = options.git ? { trackedOnly: true, git: options.git } : { trackedOnly: true };
370
+ const templateFiles = new Set(listTemplateOwnedFiles(templateRoot, manifest, trackedOnly));
340
371
  const instanceFiles = new Set(listTemplateOwnedFiles(instanceRoot, manifest));
341
- const baseFiles = baseRoot ? new Set(listTemplateOwnedFiles(baseRoot, manifest)) : void 0;
372
+ const baseFiles = baseRoot ? new Set(listTemplateOwnedFiles(baseRoot, manifest, trackedOnly)) : void 0;
342
373
  const diff = {
343
374
  added: [],
344
375
  removed: [],
@@ -687,9 +718,10 @@ var EMPTY_SUMMARY = () => ({
687
718
  });
688
719
  async function planCoreUpgrade(options) {
689
720
  const mergeFile = options.mergeFile ?? gitMergeFile;
690
- const base = new Set(listTemplateOwnedFiles(options.baseDir, options.manifest));
721
+ const trackedOnly = { trackedOnly: true };
722
+ const base = new Set(listTemplateOwnedFiles(options.baseDir, options.manifest, trackedOnly));
691
723
  const ours = new Set(listTemplateOwnedFiles(options.oursDir, options.manifest));
692
- const theirs = new Set(listTemplateOwnedFiles(options.theirsDir, options.manifest));
724
+ const theirs = new Set(listTemplateOwnedFiles(options.theirsDir, options.manifest, trackedOnly));
693
725
  const divergentPrefixes = readDivergenceConfig(options.oursDir).warnOnly.map((e) => e.prefix);
694
726
  const isDeclaredDivergent = (path) => divergentPrefixes.some((prefix) => path.startsWith(prefix));
695
727
  const paths = [.../* @__PURE__ */ new Set([...base, ...ours, ...theirs])].sort();
@@ -2401,7 +2433,7 @@ function applyMigrationCarry(instanceDir, plan) {
2401
2433
  }
2402
2434
 
2403
2435
  // src/lib/core-template-trees.ts
2404
- import { execFileSync as execFileSync2 } from "child_process";
2436
+ import { execFileSync as execFileSync3 } from "child_process";
2405
2437
  import { mkdtempSync as mkdtempSync3, rmSync as rmSync3 } from "fs";
2406
2438
  import { tmpdir as tmpdir3 } from "os";
2407
2439
  import { join as join8 } from "path";
@@ -2409,8 +2441,8 @@ function coreTag(version) {
2409
2441
  parseCoreVersion(version);
2410
2442
  return `core-v${version}`;
2411
2443
  }
2412
- var defaultGit = (args) => execFileSync2("git", args, { encoding: "utf8" });
2413
- function workingTreeMatchesTag(repo, version, git = defaultGit) {
2444
+ var defaultGit2 = (args) => execFileSync3("git", args, { encoding: "utf8" });
2445
+ function workingTreeMatchesTag(repo, version, git = defaultGit2) {
2414
2446
  const tag = coreTag(version);
2415
2447
  try {
2416
2448
  const head = git(["-C", repo, "rev-parse", "HEAD"]).trim();
@@ -2430,7 +2462,7 @@ function tagExists(repo, tag, git) {
2430
2462
  return false;
2431
2463
  }
2432
2464
  }
2433
- function materializeTemplateAtTag(repo, version, git = defaultGit) {
2465
+ function materializeTemplateAtTag(repo, version, git = defaultGit2) {
2434
2466
  const tag = coreTag(version);
2435
2467
  if (!tagExists(repo, tag, git)) {
2436
2468
  try {
@@ -2447,7 +2479,7 @@ function materializeTemplateAtTag(repo, version, git = defaultGit) {
2447
2479
  const tarball = join8(dir, ".tree.tar");
2448
2480
  try {
2449
2481
  git(["-C", repo, "archive", "--format=tar", "-o", tarball, tag]);
2450
- execFileSync2("tar", ["-x", "-f", tarball, "-C", dir]);
2482
+ execFileSync3("tar", ["-x", "-f", tarball, "-C", dir]);
2451
2483
  rmSync3(tarball, { force: true });
2452
2484
  } catch (err) {
2453
2485
  rmSync3(dir, { recursive: true, force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.199.0",
3
+ "version": "0.199.2",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",