@stigmer/cli 3.23.1-dev.20260923075205 → 3.25.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.
Files changed (57) hide show
  1. package/commands/bootstrap.d.ts.map +1 -1
  2. package/commands/bootstrap.js +16 -57
  3. package/commands/bootstrap.js.map +1 -1
  4. package/commands/config/context.d.ts.map +1 -1
  5. package/commands/config/context.js +41 -5
  6. package/commands/config/context.js.map +1 -1
  7. package/commands/up.js +1 -1
  8. package/commands/up.js.map +1 -1
  9. package/config/index.d.ts +1 -1
  10. package/config/index.d.ts.map +1 -1
  11. package/config/index.js +1 -1
  12. package/config/index.js.map +1 -1
  13. package/config/resolve.d.ts +8 -0
  14. package/config/resolve.d.ts.map +1 -1
  15. package/config/resolve.js +8 -8
  16. package/config/resolve.js.map +1 -1
  17. package/local/bootstrap.d.ts +9 -29
  18. package/local/bootstrap.d.ts.map +1 -1
  19. package/local/bootstrap.js +61 -69
  20. package/local/bootstrap.js.map +1 -1
  21. package/local/daemon/launch.d.ts +2 -2
  22. package/local/daemon/launch.d.ts.map +1 -1
  23. package/marketplace/official.d.ts.map +1 -1
  24. package/marketplace/official.js +6 -5
  25. package/marketplace/official.js.map +1 -1
  26. package/marketplace/render.d.ts.map +1 -1
  27. package/marketplace/render.js +0 -3
  28. package/marketplace/render.js.map +1 -1
  29. package/package.json +7 -7
  30. package/src/client/client.test.ts +8 -8
  31. package/src/commands/bootstrap.test.ts +19 -75
  32. package/src/commands/bootstrap.ts +19 -68
  33. package/src/commands/config/context.test.ts +171 -0
  34. package/src/commands/config/context.ts +50 -5
  35. package/src/commands/marketplace.test.ts +2 -2
  36. package/src/commands/up.ts +1 -1
  37. package/src/config/index.ts +1 -0
  38. package/src/config/resolve.test.ts +2 -2
  39. package/src/config/resolve.ts +8 -8
  40. package/src/local/bootstrap.test.ts +132 -65
  41. package/src/local/bootstrap.ts +64 -139
  42. package/src/local/daemon/launch.ts +2 -2
  43. package/src/marketplace/official.ts +6 -5
  44. package/src/marketplace/read.test.ts +2 -3
  45. package/src/marketplace/render.ts +0 -3
  46. package/local/plugins/defaults.d.ts +0 -55
  47. package/local/plugins/defaults.d.ts.map +0 -1
  48. package/local/plugins/defaults.js +0 -111
  49. package/local/plugins/defaults.js.map +0 -1
  50. package/local/system-org.d.ts +0 -15
  51. package/local/system-org.d.ts.map +0 -1
  52. package/local/system-org.js +0 -57
  53. package/local/system-org.js.map +0 -1
  54. package/src/local/plugins/defaults.test.ts +0 -271
  55. package/src/local/plugins/defaults.ts +0 -179
  56. package/src/local/system-org.test.ts +0 -124
  57. package/src/local/system-org.ts +0 -63
@@ -1,10 +1,18 @@
1
- // `stigmer config context show` — display the active CLI context.
1
+ // `stigmer config context show|set` — the active CLI context: the
2
+ // organization every command targets when neither `--org` nor
3
+ // STIGMER_ORG_ID names one.
2
4
  //
3
- // `context set --org` validates the organization against the server before
4
- // persisting, so it is wired once the backend client lands (see registerConfig).
5
+ // `set --org <slug>` checks the slug against the active backend before it
6
+ // persists: the organization must be one the caller belongs to there
7
+ // (findMyOrganizations), so a typo or a stranger's slug is refused at the
8
+ // moment it is typed, not on the next command. An empty slug clears the
9
+ // context without asking the backend, and the resolver falls back to its
10
+ // default again. The raw `config set context.organization` stays the
11
+ // unchecked escape hatch.
5
12
 
6
13
  import type { Command } from "commander";
7
- import { type Config, load, resolveContextOrganization } from "../../config/index.js";
14
+ import { type Config, activeBackendName, ensureAuthenticated, load, resolveContextOrganization, save } from "../../config/index.js";
15
+ import { CliExitError, ExitCode, UsageError } from "../../errors/index.js";
8
16
  import { CommandResult, type OutputFlags, renderResult } from "../../output/index.js";
9
17
  import { addResultFlags, resultFormat } from "../shared.js";
10
18
 
@@ -16,11 +24,48 @@ export function addContextCommands(context: Command): void {
16
24
  renderResult(buildShow(load()), resultFormat(options));
17
25
  });
18
26
  addResultFlags(show);
27
+
28
+ const set = context
29
+ .command("set")
30
+ .description("set the active context's organization, checked against the backend")
31
+ .option("--org <slug>", "an organization you belong to on the active backend (empty clears it)")
32
+ .action((options: OutputFlags & { org?: string }) => runSet(options));
33
+ addResultFlags(set);
19
34
  }
20
35
 
21
36
  function buildShow(config: Config): CommandResult {
22
37
  const organization = resolveContextOrganization(config) || "(not set)";
23
38
  const result = CommandResult.success("CLI context");
24
- result.addSection("").field("Organization", organization).field("Backend", config.backend.type);
39
+ result.addSection("").field("Organization", organization).field("Backend", activeBackendName(config));
25
40
  return result;
26
41
  }
42
+
43
+ async function runSet(flags: OutputFlags & { org?: string }): Promise<void> {
44
+ const org = flags.org;
45
+ if (org === undefined) {
46
+ throw new UsageError(
47
+ "--org is required\n\nSet it with:\n stigmer config context set --org <org>\nClear it with:\n stigmer config context set --org \"\"",
48
+ );
49
+ }
50
+
51
+ const config = load();
52
+ if (org !== "") await assertMembership(config, org);
53
+
54
+ (config.context ??= {}).organization = org;
55
+ save(config);
56
+ const message = org === "" ? "Context organization cleared" : `Context organization set to '${org}'`;
57
+ renderResult(CommandResult.success(message), resultFormat(flags));
58
+ }
59
+
60
+ async function assertMembership(config: Config, org: string): Promise<void> {
61
+ const { connectBackend } = await import("../../backend.js");
62
+ const client = connectBackend(config);
63
+ ensureAuthenticated(client.config);
64
+ const mine = await client.stigmer.organization.findMyOrganizations();
65
+ if (mine.entries.some((entry) => entry.metadata?.slug === org)) return;
66
+ throw new CliExitError(
67
+ `organization '${org}' is not one you belong to on the '${activeBackendName(config)}' backend`,
68
+ ExitCode.NotFound,
69
+ ["See the organizations you belong to with: stigmer list organizations"],
70
+ );
71
+ }
@@ -169,11 +169,11 @@ describe("marketplace show", () => {
169
169
  expect(payload.data.warnings[0].subject).toBe("ghost");
170
170
  });
171
171
 
172
- it("shows the built-in marketplace, which installs nothing by default", async () => {
172
+ it("shows the built-in marketplace", async () => {
173
173
  const outcome = await run("show", "stigmer", "--json");
174
174
  expect(outcome.exitCode).toBe(ExitCode.Success);
175
175
  const payload = JSON.parse(outcome.stdout);
176
- expect(payload.data.marketplace.defaults).toEqual([]);
176
+ expect(payload.data.marketplace.name).toBe("stigmer");
177
177
  expect(payload.data.plugins.map((p: { name: string }) => p.name)).toContain(
178
178
  "linear",
179
179
  );
@@ -74,7 +74,7 @@ async function runUp(run: UpRun, flags: OutputFlags): Promise<void> {
74
74
  return;
75
75
  }
76
76
 
77
- // The card renders when the stack is serving and seeded — the moment the
77
+ // The card renders when the stack is serving and bootstrapped — the moment the
78
78
  // detached shape would have returned — and the process then stays up,
79
79
  // mirroring the server's and runner's output, until a shutdown signal.
80
80
  const code = await launch.upForeground(run, homedir(), {
@@ -20,6 +20,7 @@ export {
20
20
  } from "./config.js";
21
21
  export {
22
22
  DEFAULT_CLOUD_CONSOLE_URL,
23
+ DEFAULT_LOCAL_ORG,
23
24
  ensureAuthenticated,
24
25
  resolveConsoleURL,
25
26
  resolveContextOrganization,
@@ -150,11 +150,11 @@ describe("resolveOrganization", () => {
150
150
  );
151
151
  });
152
152
 
153
- it("defaults to the system org in local mode when nothing is configured", () => {
153
+ it("defaults to the stigmer org in local mode when nothing is configured", () => {
154
154
  expect(resolveOrganization(localConfig())).toBe("stigmer");
155
155
  });
156
156
 
157
- it("defaults to the system org on a selfhost backend (it IS the OSS server)", () => {
157
+ it("defaults to the stigmer org on a selfhost backend (it IS the OSS server)", () => {
158
158
  expect(resolveOrganization(selfhostConfig())).toBe("stigmer");
159
159
  });
160
160
 
@@ -8,7 +8,6 @@
8
8
 
9
9
  import { CliExitError, ExitCode } from "../errors/index.js";
10
10
  import { SERVER_PORT } from "../local/constants.js";
11
- import { SYSTEM_ORG } from "../local/system-org.js";
12
11
  import { type Config, activeBackend, isCloudMode } from "./config.js";
13
12
 
14
13
  const DEFAULT_CLOUD_ENDPOINT = "api.stigmer.ai:443";
@@ -17,13 +16,14 @@ const DEFAULT_LOCAL_ENDPOINT = "localhost:7234";
17
16
  /** Well-known URL for the Stigmer Cloud web console. */
18
17
  export const DEFAULT_CLOUD_CONSOLE_URL = "https://app.stigmer.ai";
19
18
 
20
- // Local mode is single-tenant: the bootstrap `stigmer up` runs creates the
21
- // system org and installs the default plugins into it, so bare-slug
22
- // `get`/`run`/`apply` in local mode fall back to that org when nothing else
23
- // is configured. The same holds for selfhost backends — they ARE the OSS
24
- // server. Cloud mode has no implicit org — the caller must select one
25
- // (flag/env/context/login).
26
- const DEFAULT_LOCAL_ORG = SYSTEM_ORG;
19
+ /**
20
+ * The organization a local or selfhost backend falls back to when nothing
21
+ * names one, so bare-slug `get`/`run`/`apply` resolve without ceremony.
22
+ * `stigmer up` creates it on the local stack and `stigmer bootstrap` on a raw
23
+ * self-hosted server (local/bootstrap.ts). Cloud mode has no implicit org —
24
+ * the caller must select one (flag/env/context/login).
25
+ */
26
+ export const DEFAULT_LOCAL_ORG = "stigmer";
27
27
 
28
28
  /**
29
29
  * Resolve the server endpoint.
@@ -1,99 +1,166 @@
1
- // Pins the local bootstrap's shape: prepare, then run, over one client; a
2
- // preparation failure skips the run with one warning naming the retry; a
3
- // run that cannot start is one warning; a default that failed to land is one
4
- // warning per default naming `stigmer bootstrap`; nothing throws past the
5
- // bootstrap.
1
+ // Pins the bootstrap's one act over a real Connect backend: an org the
2
+ // identity already sees is `present` with no create; an absent one is created
3
+ // as the Organization as code (slug, name, self-managed) with NO label, since
4
+ // a reserved `stigmer.ai/` label is refused by a self-hosted server with
5
+ // authentication on (stigmer/stigmer#1192); a create that loses the race to
6
+ // another launcher (ALREADY_EXISTS) is `present`, not an error; any other
7
+ // refusal propagates with the server's sentence. And the local shape `up`
8
+ // runs: it announces a created org, stays quiet on a present one, and turns
9
+ // any failure into one warning naming the retry, never a throw.
6
10
 
7
- import { describe, expect, it } from "vitest";
11
+ import {
12
+ createServer as createHttp2Server,
13
+ type Http2Server,
14
+ type ServerHttp2Session,
15
+ } from "node:http2";
16
+ import type { AddressInfo } from "node:net";
17
+ import { create } from "@bufbuild/protobuf";
18
+ import { Code, ConnectError, type ConnectRouter } from "@connectrpc/connect";
19
+ import { connectNodeAdapter } from "@connectrpc/connect-node";
20
+ import {
21
+ type Organization,
22
+ OrganizationSchema,
23
+ } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
24
+ import { OrganizationCommandController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/command_pb";
25
+ import { ManagementMode } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/enum_pb";
26
+ import { OrganizationsSchema } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/io_pb";
27
+ import { OrganizationQueryController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/query_pb";
28
+ import { createNodeClient, normalizeEndpoint } from "@stigmer/sdk/node";
8
29
  import type { Stigmer } from "@stigmer/sdk";
30
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
9
31
  import type { BackendClient } from "../client/index.js";
10
- import {
11
- type BootstrapResult,
12
- type PreparedBootstrap,
13
- bootstrapLocalBackend,
14
- } from "./bootstrap.js";
32
+ import { DEFAULT_LOCAL_ORG } from "../config/resolve.js";
33
+ import { bootstrapBackend, bootstrapLocalBackend } from "./bootstrap.js";
15
34
 
16
- const stigmer = {} as unknown as Stigmer;
17
- const client = { stigmer } as unknown as BackendClient;
18
- const prepared: PreparedBootstrap = { defaults: [] };
35
+ let backend: Http2Server;
36
+ let stigmer: Stigmer;
37
+ const openSessions = new Set<ServerHttp2Session>();
19
38
 
20
- function converged(): BootstrapResult {
21
- return { org: "present", plugins: { outcomes: [], failed: [] } };
22
- }
39
+ /** The orgs the backend holds, by slug. */
40
+ let held: Map<string, Organization>;
41
+ let creates: Organization[];
42
+ /** What `create` answers with instead of creating, when set. */
43
+ let refuseCreate: ConnectError | undefined;
23
44
 
24
- describe("bootstrapLocalBackend", () => {
25
- it("prepares, then runs, over the same client", async () => {
26
- const order: string[] = [];
27
- await bootstrapLocalBackend({
28
- client,
29
- say: () => {},
30
- prepare: async () => {
31
- order.push("prepare");
32
- return prepared;
33
- },
34
- run: async (s, p) => {
35
- expect(s).toBe(stigmer);
36
- expect(p).toBe(prepared);
37
- order.push("run");
38
- return converged();
45
+ beforeAll(async () => {
46
+ const routes = (router: ConnectRouter) => {
47
+ router.service(OrganizationQueryController, {
48
+ findMyOrganizations: () =>
49
+ create(OrganizationsSchema, { entries: [...held.values()] }),
50
+ });
51
+ router.service(OrganizationCommandController, {
52
+ create: (org) => {
53
+ creates.push(org);
54
+ if (refuseCreate !== undefined) throw refuseCreate;
55
+ const slug = org.metadata?.slug ?? "";
56
+ const row = create(OrganizationSchema, org);
57
+ if (row.metadata !== undefined) row.metadata.id = `org_${slug}`;
58
+ held.set(slug, row);
59
+ return row;
39
60
  },
40
61
  });
41
- expect(order).toEqual(["prepare", "run"]);
62
+ };
63
+ backend = createHttp2Server(connectNodeAdapter({ routes }));
64
+ backend.on("session", (session) => {
65
+ openSessions.add(session);
66
+ session.on("close", () => openSessions.delete(session));
67
+ });
68
+ await new Promise<void>((resolve) => backend.listen(0, "127.0.0.1", resolve));
69
+ const port = (backend.address() as AddressInfo).port;
70
+ stigmer = createNodeClient({
71
+ baseUrl: normalizeEndpoint(`127.0.0.1:${port}`),
72
+ });
73
+ });
74
+
75
+ afterAll(async () => {
76
+ for (const session of openSessions) session.destroy();
77
+ await new Promise<void>((resolve) => backend.close(() => resolve()));
78
+ });
79
+
80
+ beforeEach(() => {
81
+ held = new Map();
82
+ creates = [];
83
+ refuseCreate = undefined;
84
+ });
85
+
86
+ describe("bootstrapBackend", () => {
87
+ it("is present, with no create, when the identity already sees the org", async () => {
88
+ held.set(
89
+ DEFAULT_LOCAL_ORG,
90
+ create(OrganizationSchema, { metadata: { slug: DEFAULT_LOCAL_ORG } }),
91
+ );
92
+ expect(await bootstrapBackend(stigmer)).toBe("present");
93
+ expect(creates).toHaveLength(0);
94
+ });
95
+
96
+ it("creates the Organization as code, with no label, when absent", async () => {
97
+ held.set("acme", create(OrganizationSchema, { metadata: { slug: "acme" } }));
98
+ expect(await bootstrapBackend(stigmer)).toBe("created");
99
+ expect(creates).toHaveLength(1);
100
+ const sent = creates[0]!;
101
+ expect(sent.metadata?.slug).toBe(DEFAULT_LOCAL_ORG);
102
+ expect(sent.metadata?.org).toBe(DEFAULT_LOCAL_ORG);
103
+ expect(sent.metadata?.name).toBe("Stigmer");
104
+ expect(sent.metadata?.labels).toEqual({});
105
+ expect(sent.spec?.managementMode).toBe(ManagementMode.self_managed);
106
+ expect(sent.spec?.description).not.toBe("");
42
107
  });
43
108
 
44
- it("skips the run when preparation fails, with one warning", async () => {
109
+ it("treats a create lost to another launcher as present", async () => {
110
+ refuseCreate = new ConnectError(
111
+ `organization '${DEFAULT_LOCAL_ORG}' already exists`,
112
+ Code.AlreadyExists,
113
+ );
114
+ expect(await bootstrapBackend(stigmer)).toBe("present");
115
+ });
116
+
117
+ it("propagates any other refusal with the server's sentence", async () => {
118
+ refuseCreate = new ConnectError("organization creation is disabled", Code.PermissionDenied);
119
+ await expect(bootstrapBackend(stigmer)).rejects.toThrow(
120
+ /organization creation is disabled/,
121
+ );
122
+ });
123
+ });
124
+
125
+ describe("bootstrapLocalBackend", () => {
126
+ const client = { stigmer: {} as unknown as Stigmer } as unknown as BackendClient;
127
+
128
+ it("runs the act over the local client and announces a created org", async () => {
45
129
  const said: string[] = [];
46
- let ran = 0;
130
+ let seen: Stigmer | undefined;
47
131
  await bootstrapLocalBackend({
48
132
  client,
49
133
  say: (line) => said.push(line),
50
- prepare: async () => {
51
- throw new Error("offline");
52
- },
53
- run: async () => {
54
- ran += 1;
55
- return converged();
134
+ bootstrap: async (s) => {
135
+ seen = s;
136
+ return "created";
56
137
  },
57
138
  });
58
- expect(ran).toBe(0);
59
- expect(said).toEqual([
60
- "Warning: could not prepare the default plugins, so the local backend was not bootstrapped. Run 'stigmer up' again to retry.",
61
- ]);
139
+ expect(seen).toBe(client.stigmer);
140
+ expect(said).toEqual([`Created the '${DEFAULT_LOCAL_ORG}' organization`]);
62
141
  });
63
142
 
64
- it("contains a run that cannot start in one warning naming the retry", async () => {
143
+ it("says nothing when the org is already present", async () => {
65
144
  const said: string[] = [];
66
145
  await bootstrapLocalBackend({
67
146
  client,
68
147
  say: (line) => said.push(line),
69
- prepare: async () => prepared,
70
- run: async () => {
71
- throw new Error("backend unreachable");
72
- },
148
+ bootstrap: async () => "present",
73
149
  });
74
- expect(said).toEqual([
75
- "Warning: failed to bootstrap the local backend. Run 'stigmer up' again to retry.",
76
- ]);
150
+ expect(said).toEqual([]);
77
151
  });
78
152
 
79
- it("names each default that did not land and the verb that retries it", async () => {
153
+ it("contains a failure in one warning naming the retry", async () => {
80
154
  const said: string[] = [];
81
155
  await bootstrapLocalBackend({
82
156
  client,
83
157
  say: (line) => said.push(line),
84
- prepare: async () => prepared,
85
- run: async () => ({
86
- org: "created",
87
- plugins: {
88
- outcomes: [
89
- { name: "assistant", action: "failed", error: "refused" },
90
- ],
91
- failed: ["assistant"],
92
- },
93
- }),
158
+ bootstrap: async () => {
159
+ throw new Error("backend unreachable");
160
+ },
94
161
  });
95
162
  expect(said).toEqual([
96
- "Warning: failed to install default plugin 'assistant'. Run 'stigmer bootstrap' to retry.",
163
+ "Warning: failed to bootstrap the local backend. Run 'stigmer up' again to retry.",
97
164
  ]);
98
165
  });
99
166
  });
@@ -1,173 +1,98 @@
1
- // What makes a backend a ready Stigmer: the system org exists and the
2
- // official marketplace's default plugins are installed into it, public.
3
- // `stigmer up` runs this against the local server it just started;
4
- // `stigmer bootstrap` runs the same two phases against whatever backend the
5
- // CLI is pointed at, for a raw self-hosted server or the platform's daily
6
- // lane. One definition, so a laptop, a container and the hosted platform
7
- // cannot drift in what "ready" means.
1
+ // What makes a backend ready for the CLI: the organization it falls back to
2
+ // on a local or selfhost backend (DEFAULT_LOCAL_ORG, `stigmer`) exists.
3
+ // Nothing is installed into it: a session with no agent runs the built-in
4
+ // assistant, so a fresh install needs no default content. `stigmer up` runs
5
+ // this against the local server it just started; `stigmer bootstrap` runs it
6
+ // against whatever backend the CLI is pointed at, a raw self-hosted server.
7
+ // One definition, so a laptop, a container and a raw server cannot drift in
8
+ // what "ready" means.
8
9
  //
9
- // Two phases, and the order matters:
10
+ // "Ensure" is find-then-create with the race closed by the server: two
11
+ // launchers racing the same check see one `already-exists`, and that IS the
12
+ // desired end state, so it is reported as `present`, never rethrown. Any
13
+ // other refusal propagates with the server's sentence; the caller decides
14
+ // how loud to be.
10
15
  //
11
- // 1. prepare: resolve the official marketplace (acquiring `@stigmer/plugins`
12
- // at the CLI's version on a release's first run) and prepare every
13
- // default. No backend is touched.
14
- // 2. run: ensure the system org, then push the prepared defaults, each
15
- // skipped when the backend already holds it at the same digest. The
16
- // push adopts a system-content row it replaces in place (a default
17
- // agent an earlier release seeded keeps its id, its instances and every
18
- // conversation bound to them), so nothing here ever deletes a row.
16
+ // The create carries no label. Writing a label in the reserved `stigmer.ai/`
17
+ // namespace needs a platform permission that a self-hosted server with
18
+ // authentication on grants to nobody, and nothing reads one on an
19
+ // organization (stigmer/stigmer#1192). Organizations created by older CLIs
20
+ // keep the `stigmer.ai/system` label they were given; it is inert.
19
21
  //
20
- // Inside `up` both phases are best-effort with their own warning naming the
21
- // retry, because the stack is already serving by the time either runs and a
22
- // seeding failure must never tear it down. Both `up` shapes (detached
23
- // and foreground) call this one function, and both seed the local server
24
- // `up` just started, never the active backend context: a CLI pointed at
25
- // cloud still gets its local stack seeded, and never has system resources
26
- // applied to its cloud org. The client is pinned to a fresh local config
27
- // (localhost:SERVER_PORT) with no auth, the trusted-local identity.
22
+ // Inside `up` the bootstrap is best-effort with one warning naming the
23
+ // retry, because the stack is already serving and a failure here must never
24
+ // tear it down. Both `up` shapes (detached and foreground) call the same
25
+ // function, and both target the local server `up` just started, never the
26
+ // active backend context: a CLI pointed at a remote backend still gets its
27
+ // local stack bootstrapped and never creates an organization elsewhere. The
28
+ // client is pinned to a fresh local config (localhost:SERVER_PORT) with no
29
+ // auth, the trusted-local identity.
28
30
 
29
- import type { Stigmer } from "@stigmer/sdk";
31
+ import { ManagementMode } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/enum_pb";
32
+ import { type Stigmer, StigmerError } from "@stigmer/sdk";
30
33
  import type { BackendClient } from "../client/index.js";
34
+ import { DEFAULT_LOCAL_ORG } from "../config/resolve.js";
31
35
  import { log } from "../logger.js";
32
- import type { ResolveOfficialOptions } from "../marketplace/official.js";
33
- import type {
34
- DefaultPluginsResult,
35
- InstallingVerb,
36
- PreparedDefault,
37
- } from "./plugins/defaults.js";
38
- import { type EnsureSystemOrgOutcome, SYSTEM_ORG } from "./system-org.js";
39
36
 
40
37
  export type Say = (line: string) => void;
41
38
 
42
- /** Everything phase 2 needs, gathered without a backend. */
43
- export interface PreparedBootstrap {
44
- readonly defaults: readonly PreparedDefault[];
45
- }
46
-
47
- export interface BootstrapResult {
48
- readonly org: EnsureSystemOrgOutcome;
49
- readonly plugins: DefaultPluginsResult;
50
- }
51
-
52
- export interface PrepareBootstrapOptions {
53
- /** How the official tree is found (injectable for tests). */
54
- readonly official?: ResolveOfficialOptions;
55
- }
56
-
57
- /** Phase 1. Throws when the catalogue cannot be resolved or a default cannot be prepared. */
58
- export async function prepareBootstrap(
59
- options: PrepareBootstrapOptions = {},
60
- ): Promise<PreparedBootstrap> {
61
- const { prepareDefaultPlugins } = await import("./plugins/defaults.js");
62
- return { defaults: await prepareDefaultPlugins(options.official) };
63
- }
39
+ export type BootstrapOutcome = "present" | "created";
64
40
 
65
41
  /**
66
- * Phase 2. Throws when the system org cannot be ensured; a default that
67
- * fails to land is reported in the result, not thrown, so the caller can
68
- * name each one. `verb` is the command running this, stamped into each
69
- * installed plugin's version message.
42
+ * Make sure the organization the CLI falls back to exists on the backend
43
+ * `stigmer` is bound to: what `stigmer bootstrap` runs. Idempotent; a lost
44
+ * create race counts as `present`.
70
45
  */
71
- export async function runBootstrap(
72
- stigmer: Stigmer,
73
- prepared: PreparedBootstrap,
74
- say: Say,
75
- verb: InstallingVerb,
76
- ): Promise<BootstrapResult> {
77
- const [{ ensureSystemOrg }, { installPreparedDefaults }] = await Promise.all(
78
- [import("./system-org.js"), import("./plugins/defaults.js")],
79
- );
80
- const org = await ensureSystemOrg(stigmer);
81
- if (org === "created") say(`Created the '${SYSTEM_ORG}' organization`);
82
- const plugins = await installPreparedDefaults(
83
- { stigmer, info: say, verb },
84
- prepared.defaults,
85
- SYSTEM_ORG,
86
- );
87
- return { org, plugins };
88
- }
89
-
90
- /** Both phases, against the backend `stigmer` is bound to: what `stigmer bootstrap` runs. */
91
- export async function bootstrapBackend(
92
- stigmer: Stigmer,
93
- say: Say,
94
- options: PrepareBootstrapOptions = {},
95
- ): Promise<BootstrapResult> {
96
- return runBootstrap(
97
- stigmer,
98
- await prepareBootstrap(options),
99
- say,
100
- "stigmer bootstrap",
101
- );
46
+ export async function bootstrapBackend(stigmer: Stigmer): Promise<BootstrapOutcome> {
47
+ const mine = await stigmer.organization.findMyOrganizations();
48
+ if (mine.entries.some((org) => org.metadata?.slug === DEFAULT_LOCAL_ORG)) {
49
+ return "present";
50
+ }
51
+ try {
52
+ // Organizations are self-owning: the org field is the slug itself, the
53
+ // shape the console's create form and the e2e harness both use.
54
+ await stigmer.organization.create({
55
+ name: "Stigmer",
56
+ slug: DEFAULT_LOCAL_ORG,
57
+ org: DEFAULT_LOCAL_ORG,
58
+ description: "The organization the Stigmer CLI uses when none is named",
59
+ managementMode: ManagementMode.self_managed,
60
+ });
61
+ return "created";
62
+ } catch (error) {
63
+ if (error instanceof StigmerError && error.code === "already-exists") {
64
+ return "present";
65
+ }
66
+ throw error;
67
+ }
102
68
  }
103
69
 
104
- /** Seams of the local bootstrap, injectable for tests; the defaults are the real phases over the real local client. */
70
+ /** Seams of the local bootstrap, injectable for tests; the defaults are the real act over the real local client. */
105
71
  export interface BootstrapDeps {
106
72
  readonly client?: BackendClient;
107
73
  readonly say?: Say;
108
- readonly prepare?: () => Promise<PreparedBootstrap>;
109
- readonly run?: (
110
- stigmer: Stigmer,
111
- prepared: PreparedBootstrap,
112
- say: Say,
113
- ) => Promise<BootstrapResult>;
74
+ readonly bootstrap?: (stigmer: Stigmer) => Promise<BootstrapOutcome>;
114
75
  }
115
76
 
116
77
  const sayToStderr: Say = (line) => {
117
78
  process.stderr.write(`${line}\n`);
118
79
  };
119
80
 
120
- /** What `stigmer up` does once the local server answers. Never throws past its own warnings. */
121
- export async function bootstrapLocalBackend(
122
- deps: BootstrapDeps = {},
123
- ): Promise<void> {
81
+ /** What `stigmer up` does once the local server answers. Never throws past its own warning. */
82
+ export async function bootstrapLocalBackend(deps: BootstrapDeps = {}): Promise<void> {
124
83
  const client = deps.client ?? (await freshLocalClient());
125
84
  const say = deps.say ?? sayToStderr;
126
85
 
127
- let prepared: PreparedBootstrap;
128
- try {
129
- prepared = await (deps.prepare ?? prepareBootstrap)();
130
- } catch (err) {
131
- log.warn("bootstrap preparation failed", { error: String(err) });
132
- say(
133
- "Warning: could not prepare the default plugins, so the local backend was not bootstrapped. Run 'stigmer up' again to retry.",
134
- );
135
- return;
136
- }
137
-
138
- let result: BootstrapResult;
86
+ let outcome: BootstrapOutcome;
139
87
  try {
140
- result = await (deps.run ?? runLocalBootstrap)(
141
- client.stigmer,
142
- prepared,
143
- say,
144
- );
88
+ outcome = await (deps.bootstrap ?? bootstrapBackend)(client.stigmer);
145
89
  } catch (err) {
146
90
  log.warn("bootstrap failed", { error: String(err) });
147
- say(
148
- "Warning: failed to bootstrap the local backend. Run 'stigmer up' again to retry.",
149
- );
91
+ say("Warning: failed to bootstrap the local backend. Run 'stigmer up' again to retry.");
150
92
  return;
151
93
  }
152
- log.debug("bootstrap complete", {
153
- org: result.org,
154
- outcomes: result.plugins.outcomes,
155
- });
156
- for (const name of result.plugins.failed) {
157
- say(
158
- `Warning: failed to install default plugin '${name}'. Run 'stigmer bootstrap' to retry.`,
159
- );
160
- }
161
- }
162
-
163
- // Phase 2 in the seam's shape, naming the verb: everything this file's
164
- // local bootstrap installs was installed by `stigmer up`.
165
- function runLocalBootstrap(
166
- stigmer: Stigmer,
167
- prepared: PreparedBootstrap,
168
- say: Say,
169
- ): Promise<BootstrapResult> {
170
- return runBootstrap(stigmer, prepared, say, "stigmer up");
94
+ log.debug("bootstrap complete", { org: outcome });
95
+ if (outcome === "created") say(`Created the '${DEFAULT_LOCAL_ORG}' organization`);
171
96
  }
172
97
 
173
98
  async function freshLocalClient(): Promise<BackendClient> {
@@ -83,11 +83,11 @@ export interface UpForegroundDeps {
83
83
  runDaemon?: (deps: InternalDaemonDeps) => Promise<number>;
84
84
  /** Resolves when the stack should shut down (default: the first SIGTERM/SIGINT). */
85
85
  waitForShutdown?: () => Promise<void>;
86
- /** Post-readiness bootstrap (default: `bootstrapLocalBackend`: prepare the defaults, ensure the org, install). */
86
+ /** Post-readiness bootstrap (default: `bootstrapLocalBackend`, which ensures the `stigmer` org). */
87
87
  bootstrap?: () => Promise<void>;
88
88
  /** Receives each line the server and runner write (default: a `[component]`-prefixed stdout mirror). */
89
89
  mirror?: OutputMirror;
90
- /** Invoked once the stack is serving and seeded — the moment a detached `up` would have returned. */
90
+ /** Invoked once the stack is serving and bootstrapped — the moment a detached `up` would have returned. */
91
91
  onReady?: () => void | Promise<void>;
92
92
  }
93
93