methodology-m 0.3.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.
Files changed (38) hide show
  1. package/bin/m.mjs +76 -0
  2. package/dist-m/CHANGELOG.md +45 -0
  3. package/dist-m/capabilities/bootstrap-root-repo/SKILL.md +138 -0
  4. package/dist-m/capabilities/decompose-story/SKILL.md +299 -0
  5. package/dist-m/capabilities/generate-acceptance-tests/SKILL.md +305 -0
  6. package/dist-m/capabilities/generate-pats/SKILL.md +131 -0
  7. package/dist-m/capabilities/scaffold-repo/SKILL.md +641 -0
  8. package/dist-m/capabilities/setup-workspace/SKILL.md +70 -0
  9. package/dist-m/capabilities/tag-release/SKILL.md +121 -0
  10. package/dist-m/capabilities/wire-orchestration/SKILL.md +351 -0
  11. package/dist-m/m.md +126 -0
  12. package/dist-m/providers/provider-interface.md +191 -0
  13. package/dist-m/providers/scm/gitlab.md +377 -0
  14. package/dist-m/schemas/pat.schema.json +161 -0
  15. package/dist-m/schemas/project.schema.json +177 -0
  16. package/package.json +27 -0
  17. package/src/commands/changelog.mjs +58 -0
  18. package/src/commands/clone.mjs +199 -0
  19. package/src/commands/diff.mjs +29 -0
  20. package/src/commands/init.mjs +51 -0
  21. package/src/commands/update.mjs +41 -0
  22. package/src/commands/version.mjs +43 -0
  23. package/src/lib/copy.mjs +20 -0
  24. package/src/lib/detect-agent.mjs +25 -0
  25. package/src/lib/diff-trees.mjs +95 -0
  26. package/src/lib/topology.mjs +62 -0
  27. package/src/lib/version-file.mjs +25 -0
  28. package/src/lib/workspace.mjs +40 -0
  29. package/src/lib/wrappers/claude.mjs +54 -0
  30. package/templates/claude/skills/bootstrap-root-repo/SKILL.md +13 -0
  31. package/templates/claude/skills/decompose-story/SKILL.md +13 -0
  32. package/templates/claude/skills/generate-acceptance-tests/SKILL.md +13 -0
  33. package/templates/claude/skills/generate-pats/SKILL.md +13 -0
  34. package/templates/claude/skills/scaffold-repo/SKILL.md +13 -0
  35. package/templates/claude/skills/setup-workspace/SKILL.md +13 -0
  36. package/templates/claude/skills/tag-release/SKILL.md +13 -0
  37. package/templates/claude/skills/wire-orchestration/SKILL.md +13 -0
  38. package/templates/claude/steering/m-steering.md +3 -0
@@ -0,0 +1,161 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://methodology-m.dev/schemas/pat.schema.json",
4
+ "title": "M PAT (Provable Acceptance Test)",
5
+ "description": "Schema for story-level and sub-task-level PAT.yaml files. Story PATs define the full acceptance contract for a user story. Sub-task PATs scope acceptance criteria to what is verifiable in isolation for a single component.",
6
+
7
+ "type": "object",
8
+ "required": ["version", "acceptance"],
9
+
10
+ "oneOf": [
11
+ {
12
+ "description": "Story-level PAT — lives in root repo at pats/<story-id>.pat.yaml",
13
+ "required": ["story"],
14
+ "properties": {
15
+ "story": true,
16
+ "version": true,
17
+ "acceptance": true
18
+ },
19
+ "not": {
20
+ "anyOf": [
21
+ { "required": ["sub-task"] },
22
+ { "required": ["parent-story"] },
23
+ { "required": ["component"] }
24
+ ]
25
+ }
26
+ },
27
+ {
28
+ "description": "Sub-task PAT — lives in managed repo at pats/<sub-task-id>.pat.yaml",
29
+ "required": ["sub-task", "parent-story", "component"],
30
+ "properties": {
31
+ "sub-task": true,
32
+ "parent-story": true,
33
+ "component": true,
34
+ "version": true,
35
+ "acceptance": true
36
+ },
37
+ "not": {
38
+ "required": ["story"]
39
+ }
40
+ }
41
+ ],
42
+
43
+ "properties": {
44
+ "story": {
45
+ "type": "string",
46
+ "description": "Story identifier (e.g. TODOM-001, PROJ-042). Present on story-level PATs only.",
47
+ "pattern": "^[A-Z]+-\\d+$"
48
+ },
49
+ "sub-task": {
50
+ "type": "string",
51
+ "description": "Sub-task identifier (e.g. TODOM-001c). Story ID + alphabetic suffix. Present on sub-task PATs only.",
52
+ "pattern": "^[A-Z]+-\\d+[a-z]$"
53
+ },
54
+ "parent-story": {
55
+ "type": "string",
56
+ "description": "Parent story identifier. Required on sub-task PATs.",
57
+ "pattern": "^[A-Z]+-\\d+$"
58
+ },
59
+ "component": {
60
+ "type": "string",
61
+ "description": "Component name from project.yaml (e.g. todo-m-mfe). Required on sub-task PATs."
62
+ },
63
+ "version": {
64
+ "type": "integer",
65
+ "description": "Schema version. Always 1 for now.",
66
+ "const": 1
67
+ },
68
+ "acceptance": {
69
+ "type": "array",
70
+ "description": "Ordered list of acceptance criteria. Order matters — later entries may depend on state created by earlier ones.",
71
+ "minItems": 1,
72
+ "items": { "$ref": "#/$defs/acceptance-criterion" }
73
+ }
74
+ },
75
+ "additionalProperties": false,
76
+
77
+ "$defs": {
78
+ "acceptance-criterion": {
79
+ "type": "object",
80
+ "required": ["id", "when", "then", "steps"],
81
+ "properties": {
82
+ "id": {
83
+ "type": "string",
84
+ "description": "Unique within this PAT file. Format: AC-NNN for single criteria, AC-NNNx (a,b,c...) when one acceptance criterion yields multiple test scenarios.",
85
+ "pattern": "^AC-\\d{3}[a-z]?$"
86
+ },
87
+ "when": {
88
+ "type": "string",
89
+ "description": "Trigger condition in plain English. User-facing, topology-agnostic. No component names, API paths, or CSS selectors."
90
+ },
91
+ "then": {
92
+ "type": "string",
93
+ "description": "Expected outcome in plain English. Observable result from the user's perspective."
94
+ },
95
+ "steps": {
96
+ "type": "array",
97
+ "description": "Ordered test steps. Each step is an object with exactly one verb key. Steps map directly to Cypress commands for story-level PATs.",
98
+ "minItems": 1,
99
+ "items": { "$ref": "#/$defs/step" }
100
+ },
101
+ "replaces": {
102
+ "type": "string",
103
+ "description": "AC this supersedes, from a previous story. Format: <story-id>/AC-<id>. Compiler must find and update the old compiled test.",
104
+ "pattern": "^[A-Z]+-\\d+/AC-\\d{3}[a-z]?$"
105
+ },
106
+ "removes": {
107
+ "type": "string",
108
+ "description": "AC this deletes, from a previous story. Format: <story-id>/AC-<id>. Compiler must find and delete the old compiled test.",
109
+ "pattern": "^[A-Z]+-\\d+/AC-\\d{3}[a-z]?$"
110
+ }
111
+ },
112
+ "additionalProperties": false
113
+ },
114
+
115
+ "step": {
116
+ "type": "object",
117
+ "description": "A single test step. Exactly one verb key per step object.",
118
+ "minProperties": 1,
119
+ "maxProperties": 1,
120
+ "oneOf": [
121
+ { "required": ["navigate"], "properties": { "navigate": { "$ref": "#/$defs/step-navigate" } } },
122
+ { "required": ["click"], "properties": { "click": { "$ref": "#/$defs/step-click" } } },
123
+ { "required": ["type"], "properties": { "type": { "$ref": "#/$defs/step-type" } } },
124
+ { "required": ["assert"], "properties": { "assert": { "$ref": "#/$defs/step-assert" } } },
125
+ { "required": ["wait"], "properties": { "wait": { "$ref": "#/$defs/step-wait" } } },
126
+ { "required": ["render"], "properties": { "render": { "$ref": "#/$defs/step-render" } } }
127
+ ]
128
+ },
129
+
130
+ "step-navigate": {
131
+ "type": "string",
132
+ "description": "Navigate to a URL path. Compiles to cy.visit(path).",
133
+ "examples": ["/", "/todos", "/login"]
134
+ },
135
+ "step-click": {
136
+ "type": "string",
137
+ "description": "Click an element by data-testid selector. Format: \"[data-testid='<id>']\". Compiles to cy.get(selector).click().",
138
+ "pattern": "^\"\\[data-testid='[^']+']\"$"
139
+ },
140
+ "step-type": {
141
+ "type": "string",
142
+ "description": "Type text into an element. Format: \"[data-testid='<id>']\" value \"<text>\". Compiles to cy.get(selector).type(text).",
143
+ "pattern": "^\"\\[data-testid='[^']+']\" value \"[^\"]*\"$"
144
+ },
145
+ "step-assert": {
146
+ "type": "string",
147
+ "description": "Assert a condition on an element. Formats: \"[selector]\" is visible | \"[selector]\" contains \"<value>\" | \"[selector]\" count > N | \"[selector]\" is disabled.",
148
+ "pattern": "^\"\\[data-testid='[^']+']\" (is visible|is disabled|contains \"[^\"]*\"|count > \\d+)$"
149
+ },
150
+ "step-wait": {
151
+ "type": "string",
152
+ "description": "Wait for a condition on an element. Same formats as assert but waits with retry. Compiles to cy.get(selector).should(...).",
153
+ "pattern": "^\"\\[data-testid='[^']+']\" (is visible|contains \"[^\"]*\")$"
154
+ },
155
+ "step-render": {
156
+ "type": "string",
157
+ "description": "Render a component with a mock setup. Sub-task PATs only (not browser-level). Not compiled to Cypress — used for component-level testing.",
158
+ "examples": ["Todo component with mocked API", "Todo component with empty mock API"]
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,177 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://methodology-m.dev/schemas/project.schema.json",
4
+ "title": "M project.yaml",
5
+ "description": "Schema for the topology manifest that lives at the root of every M-type root repo. Declares the project structure, components, providers, and configuration. Capabilities read this file to generate artefacts and resolve topology.",
6
+
7
+ "type": "object",
8
+ "required": ["project", "group", "components", "providers"],
9
+
10
+ "properties": {
11
+ "project": {
12
+ "type": "string",
13
+ "description": "Project name. Used as a prefix for managed repo names (e.g. project 'todo-m' yields 'todo-m-api-read')."
14
+ },
15
+ "group": {
16
+ "type": "string",
17
+ "description": "Full SCM group/org path (e.g. 'methodology-m/todo-m-workshop'). Used by scaffold-repo and wire-orchestration to resolve repo locations."
18
+ },
19
+ "topology": {
20
+ "type": "string",
21
+ "description": "Topology mode. 'distributed' places each component in its own repo. 'monolith-first' embeds all components in packages/ within the root repo.",
22
+ "enum": ["distributed", "monolith-first"],
23
+ "default": "distributed"
24
+ },
25
+
26
+ "providers": {
27
+ "type": "object",
28
+ "description": "Active providers by namespace. Capabilities call namespaced functions (e.g. scm.create_repo); the provider selected here determines the concrete implementation. See .m/providers/ for available providers.",
29
+ "required": ["scm"],
30
+ "properties": {
31
+ "scm": {
32
+ "type": "string",
33
+ "description": "Source code management provider. Determines which .m/providers/scm/<provider>.md file is used.",
34
+ "examples": ["gitlab", "github"]
35
+ }
36
+ },
37
+ "additionalProperties": {
38
+ "type": "string",
39
+ "description": "Future provider namespaces (e.g. ci, test, compose, deploy)."
40
+ }
41
+ },
42
+
43
+ "components": {
44
+ "type": "array",
45
+ "description": "Component catalogue. Ordered list — port assignment follows component order when ports are not explicit. The shell (frontend-host) is conventionally first.",
46
+ "minItems": 1,
47
+ "items": { "$ref": "#/$defs/component" }
48
+ },
49
+
50
+ "templates": {
51
+ "type": "object",
52
+ "description": "Template overrides per component role. Keys are roles, values are template-key or template-key@version from the org config catalogue. Omit to use defaults.",
53
+ "additionalProperties": {
54
+ "type": "string",
55
+ "pattern": "^[a-z][a-z0-9-]+(\\@[\\w\\.]+)?$"
56
+ }
57
+ },
58
+
59
+ "pat-compilation": {
60
+ "type": "object",
61
+ "description": "Test framework per component role for PAT-to-CAT compilation. Omit to use M defaults (backend: supertest+vitest, frontend: cypress, frontend-host: cypress).",
62
+ "additionalProperties": {
63
+ "type": "string"
64
+ }
65
+ },
66
+
67
+ "persistence": {
68
+ "type": "object",
69
+ "description": "Shared state mechanism between components. Affects scaffold-repo (wires dependencies) and docker-compose.yml (volumes/services).",
70
+ "properties": {
71
+ "type": {
72
+ "type": "string",
73
+ "description": "Persistence mechanism key from config catalogue.",
74
+ "examples": ["sqlite", "postgres", "dynamodb-local"]
75
+ },
76
+ "volume": {
77
+ "type": "string",
78
+ "description": "Docker volume name for shared state."
79
+ }
80
+ },
81
+ "required": ["type"]
82
+ },
83
+
84
+ "compose": {
85
+ "type": "object",
86
+ "description": "System-level assembly configuration for AOT integration and local development.",
87
+ "properties": {
88
+ "local": {
89
+ "type": "object",
90
+ "description": "Local development compose strategy.",
91
+ "properties": {
92
+ "strategy": { "type": "string", "examples": ["process", "docker-compose"] },
93
+ "script": { "type": "string" },
94
+ "stop": { "type": "string" }
95
+ }
96
+ },
97
+ "integration": {
98
+ "type": "object",
99
+ "description": "CI AOT integration compose strategy.",
100
+ "properties": {
101
+ "strategy": {
102
+ "type": "string",
103
+ "description": "Compose strategy. Reference implementation is docker-compose.",
104
+ "examples": ["docker-compose", "kubernetes", "serverless"]
105
+ },
106
+ "file": { "type": "string", "description": "Compose file path relative to root repo." },
107
+ "build": { "type": "boolean", "description": "Whether to build images (true) or pull pre-built (false)." },
108
+ "health": {
109
+ "type": "object",
110
+ "properties": {
111
+ "timeout": { "type": "integer", "description": "Seconds to wait for all endpoints.", "default": 60 },
112
+ "endpoints": {
113
+ "type": "array",
114
+ "items": { "type": "string", "format": "uri" },
115
+ "description": "Health check URLs. Derived from component ports and roles if omitted."
116
+ }
117
+ }
118
+ },
119
+ "tests": {
120
+ "type": "array",
121
+ "items": {
122
+ "type": "object",
123
+ "properties": {
124
+ "type": { "type": "string", "examples": ["cypress", "playwright", "k6"] },
125
+ "spec": { "type": "string", "description": "Glob pattern for test files." },
126
+ "base-url": { "type": "string", "format": "uri" }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ },
135
+
136
+ "additionalProperties": false,
137
+
138
+ "$defs": {
139
+ "component": {
140
+ "type": "object",
141
+ "required": ["name", "type", "location"],
142
+ "properties": {
143
+ "name": {
144
+ "type": "string",
145
+ "description": "Component identifier. Used in CI variable names (M_TOKEN_<NAME_UPPER>), compose service names, and repo name suffixes.",
146
+ "pattern": "^[a-z][a-z0-9-]+$"
147
+ },
148
+ "type": {
149
+ "type": "string",
150
+ "description": "'embedded' lives inside the root repo (packages/<name>). 'referenced' lives in its own repo.",
151
+ "enum": ["embedded", "referenced"]
152
+ },
153
+ "location": {
154
+ "type": "string",
155
+ "description": "For embedded: relative path (e.g. ./packages/shell). For referenced: full SCM path (e.g. methodology-m/todo-m-workshop/todo-m-api-read)."
156
+ },
157
+ "role": {
158
+ "type": "string",
159
+ "description": "Component role. Determines scaffold template, CI template, health check convention, and port defaults.",
160
+ "enum": ["frontend-host", "frontend", "backend"]
161
+ },
162
+ "tag": {
163
+ "type": ["string", "null"],
164
+ "description": "Version pin. Null (~) means nothing released yet. Set by auto-tag after merge transaction. Format: v<major>.<minor>.<patch>.",
165
+ "pattern": "^v\\d+\\.\\d+\\.\\d+$"
166
+ },
167
+ "port": {
168
+ "type": "integer",
169
+ "description": "Service port for compose and health checks. Convention: frontend-host=3000, frontend=3001, backends=3002+. If omitted, derived from component order and role.",
170
+ "minimum": 1,
171
+ "maximum": 65535
172
+ }
173
+ },
174
+ "additionalProperties": false
175
+ }
176
+ }
177
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "methodology-m",
3
+ "version": "0.3.1",
4
+ "description": "CLI for distributing Methodology M into AI-driven software projects",
5
+ "type": "module",
6
+ "bin": {
7
+ "m": "./bin/m.mjs",
8
+ "methodology-m": "./bin/m.mjs"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "dist-m/",
14
+ "templates/"
15
+ ],
16
+ "scripts": {
17
+ "snapshot": "node scripts/snapshot-dist.mjs",
18
+ "prepublishOnly": "node scripts/snapshot-dist.mjs",
19
+ "test": "node --test test/"
20
+ },
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "keywords": ["methodology-m", "ai", "sdlc", "delivery", "multi-repo"],
25
+ "license": "MIT",
26
+ "author": "Textology Labs Ltd."
27
+ }
@@ -0,0 +1,58 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * m changelog [version]
6
+ * Print the bundled changelog, optionally filtered to a specific version.
7
+ */
8
+ export async function changelog(args) {
9
+ const targetVersion = args[0];
10
+ const changelogPath = join(import.meta.dirname, '..', '..', 'dist-m', 'CHANGELOG.md');
11
+
12
+ if (!existsSync(changelogPath)) {
13
+ console.error('Changelog not found. Reinstall the CLI package.');
14
+ process.exit(1);
15
+ }
16
+
17
+ const content = readFileSync(changelogPath, 'utf8');
18
+
19
+ if (!targetVersion) {
20
+ console.log(content);
21
+ return;
22
+ }
23
+
24
+ // Extract the section for a specific version
25
+ const section = extractVersion(content, targetVersion);
26
+ if (section) {
27
+ console.log(section);
28
+ } else {
29
+ console.error(`Version ${targetVersion} not found in changelog.`);
30
+ process.exit(1);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Extract a single version's section from a markdown changelog.
36
+ * Looks for ## [version] or ## version headings.
37
+ */
38
+ function extractVersion(content, version) {
39
+ const lines = content.split('\n');
40
+ const normalized = version.replace(/^v/, '');
41
+ let capturing = false;
42
+ const result = [];
43
+
44
+ for (const line of lines) {
45
+ // Match ## [0.3.0], ## [v0.3.0], ## 0.3.0, etc.
46
+ if (line.match(/^## /)) {
47
+ if (capturing) break; // Hit the next version heading — stop
48
+ if (line.includes(normalized)) {
49
+ capturing = true;
50
+ }
51
+ }
52
+ if (capturing) {
53
+ result.push(line);
54
+ }
55
+ }
56
+
57
+ return result.length ? result.join('\n').trim() : null;
58
+ }
@@ -0,0 +1,199 @@
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import { join, basename, resolve } from 'node:path';
3
+ import { execSync } from 'node:child_process';
4
+ import { readTopology, getReferencedRepos } from '../lib/topology.mjs';
5
+ import { generateWorkspace } from '../lib/workspace.mjs';
6
+
7
+ /**
8
+ * m clone <root-repo-url> [--ide vscode]
9
+ * m clone (run from inside an existing root repo)
10
+ */
11
+ export async function clone(args) {
12
+ const flags = parseFlags(args);
13
+ const ide = flags.ide || 'vscode';
14
+
15
+ if (flags.url) {
16
+ await cloneFromUrl(flags.url, ide);
17
+ } else {
18
+ await cloneFromExisting(ide);
19
+ }
20
+ }
21
+
22
+ async function cloneFromUrl(url, ide) {
23
+ // Derive the root repo directory name from the URL
24
+ const repoName = basename(url, '.git');
25
+
26
+ // Create a parent workspace directory named after the project
27
+ // We'll rename it after reading project.yaml if the project name differs
28
+ const parentDir = resolve(repoName + '-workspace');
29
+
30
+ if (existsSync(parentDir)) {
31
+ console.error(`Directory ${parentDir} already exists. Remove it or run "m clone" from inside the root repo.`);
32
+ process.exit(1);
33
+ }
34
+
35
+ mkdirSync(parentDir, { recursive: true });
36
+
37
+ // 1. Clone root repo
38
+ console.log(`Cloning root repo: ${url}`);
39
+ gitClone(url, join(parentDir, repoName));
40
+ console.log(` ✓ ${repoName}/`);
41
+
42
+ // 2. Read topology
43
+ const rootDir = join(parentDir, repoName);
44
+ const topology = readTopology(rootDir);
45
+ const repos = getReferencedRepos(topology);
46
+
47
+ console.log(` Project: ${topology.project} (${repos.length} managed repos)`);
48
+ console.log('');
49
+
50
+ // 3. Clone managed repos
51
+ const clonedDirs = [repoName];
52
+
53
+ for (const repo of repos) {
54
+ const repoDir = join(parentDir, repo.repoName);
55
+ if (existsSync(repoDir)) {
56
+ console.log(` · ${repo.repoName}/ already exists (skipped)`);
57
+ clonedDirs.push(repo.repoName);
58
+ continue;
59
+ }
60
+
61
+ // Derive clone URL from root URL pattern + component location
62
+ const cloneUrl = deriveCloneUrl(url, repo.location);
63
+ console.log(` Cloning ${repo.repoName}...`);
64
+ gitClone(cloneUrl, repoDir);
65
+ console.log(` ✓ ${repo.repoName}/`);
66
+ clonedDirs.push(repo.repoName);
67
+ }
68
+
69
+ // 4. Generate workspace file
70
+ console.log('');
71
+ const wsPath = generateWorkspace(parentDir, topology.project, clonedDirs, { ide });
72
+ console.log(` ✓ ${basename(wsPath)} generated`);
73
+
74
+ // 5. Summary
75
+ printSummary(parentDir, topology.project, clonedDirs, wsPath, ide);
76
+ }
77
+
78
+ async function cloneFromExisting(ide) {
79
+ // Assume we're inside the root repo — find project.yaml
80
+ const cwd = process.cwd();
81
+ let rootDir = cwd;
82
+
83
+ if (!existsSync(join(rootDir, 'project.yaml'))) {
84
+ console.error('No project.yaml found. Run this from inside the root repo, or provide a URL: m clone <url>');
85
+ process.exit(1);
86
+ }
87
+
88
+ const parentDir = resolve(rootDir, '..');
89
+ const repoName = basename(rootDir);
90
+
91
+ const topology = readTopology(rootDir);
92
+ const repos = getReferencedRepos(topology);
93
+
94
+ console.log(`Project: ${topology.project} (${repos.length} managed repos)`);
95
+ console.log('');
96
+
97
+ const clonedDirs = [repoName];
98
+
99
+ for (const repo of repos) {
100
+ const repoDir = join(parentDir, repo.repoName);
101
+ if (existsSync(repoDir)) {
102
+ console.log(` · ${repo.repoName}/ already exists (skipped)`);
103
+ clonedDirs.push(repo.repoName);
104
+ continue;
105
+ }
106
+
107
+ // Try to derive URL from root repo's remote
108
+ const rootRemote = getGitRemote(rootDir);
109
+ if (!rootRemote) {
110
+ console.error(` ✗ Cannot determine clone URL for ${repo.repoName} — root repo has no remote`);
111
+ continue;
112
+ }
113
+
114
+ const cloneUrl = deriveCloneUrl(rootRemote, repo.location);
115
+ console.log(` Cloning ${repo.repoName}...`);
116
+ gitClone(cloneUrl, repoDir);
117
+ console.log(` ✓ ${repo.repoName}/`);
118
+ clonedDirs.push(repo.repoName);
119
+ }
120
+
121
+ // Generate workspace file
122
+ console.log('');
123
+ const wsPath = generateWorkspace(parentDir, topology.project, clonedDirs, { ide });
124
+ console.log(` ✓ ${basename(wsPath)} generated`);
125
+
126
+ printSummary(parentDir, topology.project, clonedDirs, wsPath, ide);
127
+ }
128
+
129
+ function gitClone(url, dest) {
130
+ execSync(`git clone "${url}" "${dest}"`, { stdio: 'pipe' });
131
+ }
132
+
133
+ function getGitRemote(repoDir) {
134
+ try {
135
+ return execSync('git remote get-url origin', { cwd: repoDir, stdio: 'pipe' })
136
+ .toString()
137
+ .trim();
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Derive a clone URL for a managed repo from the root repo URL and the
145
+ * component location in project.yaml.
146
+ *
147
+ * Root URL: git@gitlab.com:acme/todo-m-workshop/todo-m-root.git
148
+ * Location: acme/todo-m-workshop/todo-m-api-read
149
+ * Result: git@gitlab.com:acme/todo-m-workshop/todo-m-api-read.git
150
+ *
151
+ * Supports both SSH (git@host:path) and HTTPS (https://host/path) formats.
152
+ */
153
+ function deriveCloneUrl(rootUrl, location) {
154
+ // SSH format: git@gitlab.com:group/subgroup/repo.git
155
+ const sshMatch = rootUrl.match(/^(git@[^:]+:)/);
156
+ if (sshMatch) {
157
+ return `${sshMatch[1]}${location}.git`;
158
+ }
159
+
160
+ // HTTPS format: https://gitlab.com/group/subgroup/repo.git
161
+ const httpsMatch = rootUrl.match(/^(https?:\/\/[^/]+)\//);
162
+ if (httpsMatch) {
163
+ return `${httpsMatch[1]}/${location}.git`;
164
+ }
165
+
166
+ // Fallback: just replace the last path segment
167
+ throw new Error(
168
+ `Cannot derive clone URL for "${location}" from root URL "${rootUrl}". ` +
169
+ `Provide the full URL manually.`
170
+ );
171
+ }
172
+
173
+ function parseFlags(args) {
174
+ const flags = { url: null, ide: null };
175
+
176
+ for (let i = 0; i < args.length; i++) {
177
+ if (args[i] === '--ide' && args[i + 1]) {
178
+ flags.ide = args[++i];
179
+ } else if (!args[i].startsWith('-')) {
180
+ flags.url = args[i];
181
+ }
182
+ }
183
+
184
+ return flags;
185
+ }
186
+
187
+ function printSummary(parentDir, projectName, dirs, wsPath, ide) {
188
+ console.log('');
189
+ console.log(`Workspace ready at ${parentDir}/`);
190
+ console.log('');
191
+ for (const d of dirs) console.log(` ${d}/`);
192
+ console.log(` ${basename(wsPath)}`);
193
+ console.log('');
194
+
195
+ if (ide === 'vscode') {
196
+ console.log(`Open in VS Code:`);
197
+ console.log(` code ${wsPath}`);
198
+ }
199
+ }
@@ -0,0 +1,29 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { readInstalledVersion, readAvailableVersion } from '../lib/version-file.mjs';
4
+ import { diffTrees, formatDiff } from '../lib/diff-trees.mjs';
5
+
6
+ export async function diff(args) {
7
+ const target = resolve(args[0] || '.');
8
+ const installed = readInstalledVersion(target);
9
+ const available = readAvailableVersion();
10
+
11
+ if (!installed) {
12
+ console.error('M is not installed in this project. Run "m init" first.');
13
+ process.exit(1);
14
+ }
15
+
16
+ const installedDir = join(target, '.m');
17
+ const availableDir = join(import.meta.dirname, '..', '..', 'dist-m');
18
+
19
+ if (!existsSync(availableDir)) {
20
+ console.error('dist-m/ not found. Reinstall the CLI package.');
21
+ process.exit(1);
22
+ }
23
+
24
+ console.log(`Comparing installed v${installed} against available v${available}`);
25
+ console.log('');
26
+
27
+ const result = diffTrees(installedDir, availableDir);
28
+ console.log(formatDiff(result));
29
+ }