create-substrat 0.6.1 → 0.6.3

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/index.js CHANGED
@@ -21,16 +21,23 @@ import { DEV_SERVERS } from './dev-servers.js';
21
21
  const HERE = dirname(fileURLToPath(import.meta.url));
22
22
  const TEMPLATE = join(HERE, 'template');
23
23
 
24
- // Published today; Substrat is 0.x, so these are caret ranges on the current minor.
25
- // The runtime packages release together off one version line, so one constant is right
26
- // for all of them.
27
- const SUBSTRAT = '^0.71.0';
28
- // Engines do NOT share a line — each one versions on its own, so a single ENGINES
29
- // constant silently stops resolving the moment any engine crosses a minor. It did:
30
- // this file pinned `^0.3.37` while workorder had moved to 0.4.x and invoicing to 0.6.x,
31
- // so a freshly scaffolded project could not install. One pin per engine, deliberately.
32
- const ENGINE_WORKORDER = '^0.4.3';
33
- const ENGINE_INVOICING = '^0.6.2';
24
+ // Substrat is 0.x, so these are caret ranges on the current minor — a caret on 0.x
25
+ // locks the minor, which is what makes a stale pin here a scaffold four minors behind
26
+ // rather than a scaffold that quietly upgrades.
27
+ //
28
+ // EMITTED, NOT EDITED. `pnpm lint:pins` writes this block from each package's own
29
+ // version and `--check` fails CI on drift; `version-packages` runs the write, so a
30
+ // bump and its pins land in the same PR. Hand-maintained, they went stale twice: once
31
+ // resolving nothing (`^0.3.37` after workorder moved to 0.4.x), and once resolving
32
+ // fine but freezing the template against packages nobody ships, which is worse — the
33
+ // scaffold's own tests stayed green while the surface moved underneath them.
34
+ //
35
+ // The runtime packages release together off one version line (the changesets `fixed`
36
+ // group), so one constant is right for all of them. Engines do NOT share a line —
37
+ // each versions on its own, so one pin per engine, deliberately.
38
+ const SUBSTRAT = '^0.76.0';
39
+ const ENGINE_WORKORDER = '^0.6.3';
40
+ const ENGINE_INVOICING = '^0.7.3';
34
41
  const BOUNDARY_LINT = '^0.0.7';
35
42
 
36
43
  const DOCS = 'https://substrat.net';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-substrat",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Scaffold a Substrat vertical — `npm create substrat <dir>`.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -19,8 +19,13 @@
19
19
  * This file lives in `.substrat/` — the tool-neutral home, next to `playbook.md` —
20
20
  * not in `.claude/`. `.claude/settings.json` is a three-line adapter that runs it,
21
21
  * and any other client that grows a session hook binds the same way. It also
22
- * means the plugin distribution (#753) can ship this script unchanged rather than
23
- * forking it.
22
+ * means the plugin distribution (#753) ships this script unchanged rather than
23
+ * forking it: `plugin/substrat/scripts/session-start.mjs` is emitted from this
24
+ * file byte-for-byte, and `pnpm lint:plugin --check` fails if the two diverge.
25
+ *
26
+ * The plugin copy is what reaches a project scaffolded before this hook existed.
27
+ * When a project owns its own copy, the plugin's stays silent — see
28
+ * `isPluginCopy()` below — so a scaffolded project announces itself once.
24
29
  *
25
30
  * ## Deliberately silent, and deliberately offline
26
31
  *
@@ -33,8 +38,9 @@
33
38
  *
34
39
  * Opt out by creating `.substrat/no-session-context`.
35
40
  */
36
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
37
- import { dirname, join } from 'node:path';
41
+ import { existsSync, readFileSync, realpathSync, writeFileSync, mkdirSync } from 'node:fs';
42
+ import { dirname, join, relative, isAbsolute } from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
38
44
 
39
45
  const DOCS = 'https://substrat.net';
40
46
  const KERNEL = '@substrat-run/kernel';
@@ -42,6 +48,33 @@ const KERNEL = '@substrat-run/kernel';
42
48
  /** Where the project is. Claude Code sets this; fall back to the cwd it ran us in. */
43
49
  const root = process.env.CLAUDE_PROJECT_DIR || process.cwd();
44
50
 
51
+ /** Where this script is. The two copies differ only in where they sit on disk. */
52
+ const self = fileURLToPath(import.meta.url);
53
+
54
+ /** Symlinks resolved, so a project reached through one is not mistaken for elsewhere. */
55
+ function real(path) {
56
+ try {
57
+ return realpathSync(path);
58
+ } catch {
59
+ return path;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * True when we are the plugin's copy rather than the project's own.
65
+ *
66
+ * Asked positionally instead of by a flag, because the two copies must stay
67
+ * byte-identical for `lint:plugin` to have anything to check: a copy that is
68
+ * invoked differently is a copy that can be edited differently. Both sides are
69
+ * resolved first — on macOS a project under `/tmp` is handed to us as
70
+ * `/private/tmp`, and an unresolved compare would read the project's own copy as
71
+ * foreign and silence the very hook that should speak.
72
+ */
73
+ function isPluginCopy() {
74
+ const inside = relative(real(root), real(self));
75
+ return inside.startsWith('..') || isAbsolute(inside);
76
+ }
77
+
45
78
  /** The marker: which kernel version this project last announced. */
46
79
  const MARKER = join(root, '.substrat', '.docs-pin');
47
80
  const OPT_OUT = join(root, '.substrat', 'no-session-context');
@@ -67,6 +100,11 @@ function main() {
67
100
  // file to invent, and nothing to keep in sync.
68
101
  if (!pkg?.substrat) silent();
69
102
 
103
+ // The project's own copy wins. It ships beside the playbook it points at, so it
104
+ // is the one that matches what is actually checked out here; the plugin's exists
105
+ // for projects scaffolded before this hook did, and must not double-announce.
106
+ if (isPluginCopy() && existsSync(join(root, '.substrat', 'hooks', 'session-start.mjs'))) silent();
107
+
70
108
  if (existsSync(OPT_OUT)) silent();
71
109
 
72
110
  // What is actually installed beats what package.json asked for: a caret range on
@@ -164,8 +164,15 @@ mountPlatformSurface<Env>(app, {
164
164
  hostFor,
165
165
  roles: ROLES,
166
166
  ownerRoleKey: OWNER_ROLE_KEY,
167
- onProvision: (env, b) => sweeper(env).noteScope(b.tenantId, b.scopeId),
168
- onDeleteScope: (env, s) => sweeper(env).forgetScope(s),
167
+ // Both hooks are `Promise<void>`: the roster's own count is its business, and
168
+ // returning it here would make the platform's response shape depend on what a
169
+ // vertical happens to hand back. Await, discard.
170
+ onProvision: async (env, b) => {
171
+ await sweeper(env).noteScope(b.tenantId, b.scopeId);
172
+ },
173
+ onDeleteScope: async (env, s) => {
174
+ await sweeper(env).forgetScope(s);
175
+ },
169
176
  });
170
177
 
171
178
  // Unmatched /api/* fails as JSON; everything else gets a pointer, not a UI —
@@ -160,14 +160,18 @@ describe('bike-shop scenario', () => {
160
160
  expect(underlag[0]!.status).toBe('open');
161
161
  expect(underlag[0]!.total).toBe('336.5');
162
162
 
163
- const detail = await greta.invoke<{ lines: { source_id: string; source_type: string }[] }>(
164
- 'invoicing/get',
165
- { underlagId: underlag[0]!.id },
166
- );
163
+ // Provenance is two-level: `document_*` is which delivery produced the line
164
+ // (this work order), `source_*` is what the line itself is (time vs material).
165
+ // Both come from the engine's own event payload — the vertical never joins
166
+ // into invoicing's tables to find out.
167
+ const detail = await greta.invoke<
168
+ { lines: { document_id: string; document_type: string; source_type: string | null }[] }
169
+ >('invoicing/get', { underlagId: underlag[0]!.id });
167
170
  expect(detail.lines).toHaveLength(2);
168
- expect(detail.lines.every((l) => l.source_type === 'workorder' && l.source_id === repairId)).toBe(
169
- true,
170
- );
171
+ expect(
172
+ detail.lines.every((l) => l.document_type === 'workorder' && l.document_id === repairId),
173
+ ).toBe(true);
174
+ expect(detail.lines.map((l) => l.source_type).sort()).toEqual(['material', 'time']);
171
175
  });
172
176
 
173
177
  it('7. portal isolation: Lisbeth sees her repair, Otto sees nothing', async () => {