@clossys/launcher 0.1.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.
@@ -0,0 +1,106 @@
1
+ /** Ternary retained by the installed CLI. */
2
+ export type WorkspaceState = "satisfied" | "violated" | "indeterminate";
3
+ /** Captured stdout/stderr from a host command. */
4
+ export interface CommandResult {
5
+ readonly status: number | null;
6
+ readonly stdout: string;
7
+ readonly stderr: string;
8
+ }
9
+ /** Filesystem and process ports. Tests inject these; the CLI supplies Node. */
10
+ export interface WorkspaceHost {
11
+ readonly cwd: string;
12
+ readonly env: NodeJS.ProcessEnv;
13
+ readonly isTTY: boolean;
14
+ now(): string;
15
+ exists(path: string): boolean;
16
+ isDirectory(path: string): boolean;
17
+ readText(path: string): string | null;
18
+ writeText(path: string, contents: string): void;
19
+ mkdirp(path: string): void;
20
+ readDir(path: string): string[];
21
+ run(command: string, args: readonly string[], options?: {
22
+ cwd?: string;
23
+ }): CommandResult;
24
+ prompt(message: string, choices: readonly string[]): string | null;
25
+ }
26
+ /** On-disk hub marker generated on the consumer hub. Packed template: skeleton/.clossys/workspace.json. */
27
+ export interface HubDocument {
28
+ readonly schemaVersion: 1;
29
+ readonly kind: "account-hub";
30
+ readonly owner: string;
31
+ readonly repository: string;
32
+ }
33
+ export interface InventoryObservation {
34
+ readonly status: "missing" | "empty" | "populated";
35
+ readonly count: number;
36
+ }
37
+ /** Read-only pin and inventory report after adopt or resume. Never uninstalls. */
38
+ export interface HubHealthReport {
39
+ readonly marker: "present" | "missing";
40
+ readonly inventory: InventoryObservation;
41
+ readonly advisorPin: {
42
+ readonly dependencies?: string;
43
+ readonly devDependencies?: string;
44
+ readonly live?: string;
45
+ };
46
+ readonly dualPin: boolean;
47
+ readonly extraClossys: readonly string[];
48
+ }
49
+ export interface CwdObservation {
50
+ readonly absolutePath: string;
51
+ readonly empty: boolean;
52
+ readonly git: boolean;
53
+ readonly githubOwner?: string;
54
+ readonly githubRepository?: string;
55
+ readonly hub?: HubDocument;
56
+ readonly looksLikeFoundry: boolean;
57
+ readonly inventory?: InventoryObservation;
58
+ }
59
+ export interface WorkspaceObservation {
60
+ readonly cwd: CwdObservation;
61
+ readonly ownerCandidates: readonly string[];
62
+ readonly envOwner?: string;
63
+ readonly remoteDefaultHub?: {
64
+ readonly owner: string;
65
+ readonly repository: string;
66
+ };
67
+ readonly advisorVersion?: string;
68
+ readonly ghAvailable: boolean;
69
+ readonly gitAvailable: boolean;
70
+ }
71
+ export interface WorkspacePlanCreate {
72
+ readonly action: "create";
73
+ readonly owner: string;
74
+ readonly repository: string;
75
+ readonly directory: string;
76
+ readonly advisorVersion: string;
77
+ }
78
+ export interface WorkspacePlanResume {
79
+ readonly action: "resume";
80
+ readonly owner: string;
81
+ readonly repository: string;
82
+ readonly directory: string;
83
+ readonly clone: boolean;
84
+ }
85
+ export interface WorkspacePlanAdopt {
86
+ readonly action: "adopt";
87
+ readonly owner: string;
88
+ readonly repository: string;
89
+ readonly directory: string;
90
+ readonly advisorVersion: string;
91
+ /** Absolute path of a populated inventory document to copy. Absent when cwd already has one. */
92
+ readonly inventorySource?: string;
93
+ }
94
+ export type WorkspacePlan = WorkspacePlanCreate | WorkspacePlanResume | WorkspacePlanAdopt;
95
+ export interface WorkspaceRefusal {
96
+ readonly action: "refuse";
97
+ readonly state: WorkspaceState;
98
+ readonly message: string;
99
+ }
100
+ export type WorkspaceDecision = WorkspacePlan | WorkspaceRefusal;
101
+ export interface WorkspaceApplyResult {
102
+ readonly state: "satisfied";
103
+ readonly message: string;
104
+ readonly health: HubHealthReport;
105
+ }
106
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@clossys/launcher",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "bin": {
17
+ "launcher": "dist/cli.js",
18
+ "launcher-check": "dist/check-cli.js"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src",
23
+ "!src/**/*.test.ts",
24
+ "skeleton",
25
+ "README.md",
26
+ "CHANGELOG.md",
27
+ "LICENSE"
28
+ ],
29
+ "sideEffects": false,
30
+ "description": "Foundry workspace scaffolder: create, resume, or adopt a GitHub repository as the account hub. Executable tooling, not a role.",
31
+ "keywords": [
32
+ "workspace",
33
+ "github",
34
+ "npx",
35
+ "onboarding",
36
+ "typescript"
37
+ ],
38
+ "author": "Clossys",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/clossys/foundry.git",
42
+ "directory": "packages/launcher"
43
+ },
44
+ "bugs": {
45
+ "url": "https://github.com/clossys/foundry/issues"
46
+ },
47
+ "homepage": "https://github.com/clossys/foundry/tree/main/packages/launcher#readme",
48
+ "engines": {
49
+ "node": ">=20"
50
+ },
51
+ "scripts": {
52
+ "build": "tsc -p tsconfig.json",
53
+ "prepublishOnly": "node ../../scripts/check-name-collision.mjs . && npm run build",
54
+ "typecheck": "tsc -p tsconfig.json --noEmit",
55
+ "test": "vitest run"
56
+ },
57
+ "devDependencies": {
58
+ "@types/node": "^26.5.1",
59
+ "typescript": "~6.0.0",
60
+ "vitest": "^5.0.0"
61
+ },
62
+ "publishConfig": {
63
+ "registry": "https://registry.npmjs.org",
64
+ "access": "public"
65
+ }
66
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "repositories": []
4
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "kind": "account-hub",
4
+ "owner": "__OWNER__",
5
+ "repository": "__OWNER__/__REPOSITORY_NAME__"
6
+ }
@@ -0,0 +1,29 @@
1
+ # Account workspace
2
+
3
+ This repository is the account hub for Foundry packages. It inventories
4
+ where packages are installed and coordinates engagement. It is not a
5
+ product application. Do not install the whole catalogue here.
6
+
7
+ ## Open this folder
8
+
9
+ Open this repository in your coding agent (Cursor, Claude, Codex, or
10
+ another agent that can work a GitHub checkout). Advisor is read-only
11
+ until you approve a next action.
12
+
13
+ ## Resume later
14
+
15
+ From this folder, or from any empty directory on a machine that can see
16
+ the same GitHub owner:
17
+
18
+ ```bash
19
+ npx @clossys/launcher
20
+ ```
21
+
22
+ Public npm reads are credentialless. Do not add a token or private
23
+ registry mapping for `@clossys`.
24
+
25
+ ## What this hub pins
26
+
27
+ Exact `@clossys/advisor` is a development dependency so the engagement
28
+ gate can run against evidence you own. Other roles are pinned in the
29
+ repositories that actually do the work, not dumped into this hub.
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "__REPOSITORY_NAME__",
3
+ "private": true,
4
+ "description": "Account workspace hub for Foundry engagement. Not a product application.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "devDependencies": {
11
+ "@clossys/advisor": "__ADVISOR_VERSION__"
12
+ }
13
+ }
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { isDirectInvocation } from "./cli.js";
5
+ import { planWorkspace } from "./core.js";
6
+ import type {
7
+ CommandResult,
8
+ CwdObservation,
9
+ InventoryObservation,
10
+ WorkspaceHost,
11
+ WorkspaceObservation,
12
+ } from "./types.js";
13
+
14
+ export const CHECK_USAGE = `Usage: launcher-check --input <observation.json>
15
+
16
+ Grade a captured workspace observation through planWorkspace. This bin does
17
+ not create, resume, or appoint a hub.
18
+
19
+ Exit codes: 0 = a create, resume, or adopt plan, 1 = a known refusal,
20
+ 2 = indeterminate or the input could not be read.`;
21
+
22
+ export class LauncherCheckInputError extends Error {}
23
+
24
+ function isRecord(value: unknown): value is Record<string, unknown> {
25
+ return typeof value === "object" && value !== null && !Array.isArray(value);
26
+ }
27
+
28
+ function isText(value: unknown): value is string {
29
+ return typeof value === "string" && value.trim() !== "";
30
+ }
31
+
32
+ function parseInventoryObservation(value: unknown): InventoryObservation | undefined {
33
+ if (value === undefined) return undefined;
34
+ if (!isRecord(value)) {
35
+ throw new LauncherCheckInputError("observation.cwd.inventory must be an object");
36
+ }
37
+ if (value.status !== "missing" && value.status !== "empty" && value.status !== "populated") {
38
+ throw new LauncherCheckInputError("observation.cwd.inventory.status must be missing, empty, or populated");
39
+ }
40
+ if (typeof value.count !== "number" || !Number.isInteger(value.count) || value.count < 0) {
41
+ throw new LauncherCheckInputError("observation.cwd.inventory.count must be a nonnegative integer");
42
+ }
43
+ return { status: value.status, count: value.count };
44
+ }
45
+
46
+ function parseCwd(value: unknown): CwdObservation {
47
+ if (!isRecord(value) || !isText(value.absolutePath)) {
48
+ throw new LauncherCheckInputError("observation.cwd.absolutePath must be a nonempty string");
49
+ }
50
+ if (typeof value.empty !== "boolean" || typeof value.git !== "boolean" || typeof value.looksLikeFoundry !== "boolean") {
51
+ throw new LauncherCheckInputError("observation.cwd must declare empty, git, and looksLikeFoundry booleans");
52
+ }
53
+ if (value.githubOwner !== undefined && !isText(value.githubOwner)) {
54
+ throw new LauncherCheckInputError("observation.cwd.githubOwner must be a string");
55
+ }
56
+ if (value.githubRepository !== undefined && !isText(value.githubRepository)) {
57
+ throw new LauncherCheckInputError("observation.cwd.githubRepository must be a string");
58
+ }
59
+ const inventory = parseInventoryObservation(value.inventory);
60
+ return {
61
+ absolutePath: value.absolutePath,
62
+ empty: value.empty,
63
+ git: value.git,
64
+ looksLikeFoundry: value.looksLikeFoundry,
65
+ ...(isText(value.githubOwner) ? { githubOwner: value.githubOwner } : {}),
66
+ ...(isText(value.githubRepository) ? { githubRepository: value.githubRepository } : {}),
67
+ ...(inventory === undefined ? {} : { inventory }),
68
+ };
69
+ }
70
+
71
+ export function parseObservation(value: unknown): WorkspaceObservation {
72
+ if (!isRecord(value)) throw new LauncherCheckInputError("observation must be a JSON object");
73
+ if (!Array.isArray(value.ownerCandidates) || value.ownerCandidates.some((item) => typeof item !== "string")) {
74
+ throw new LauncherCheckInputError("observation.ownerCandidates must be an array of strings");
75
+ }
76
+ if (typeof value.ghAvailable !== "boolean" || typeof value.gitAvailable !== "boolean") {
77
+ throw new LauncherCheckInputError("observation must declare ghAvailable and gitAvailable booleans");
78
+ }
79
+ if (value.envOwner !== undefined && !isText(value.envOwner)) {
80
+ throw new LauncherCheckInputError("observation.envOwner must be a string");
81
+ }
82
+ if (value.advisorVersion !== undefined && !isText(value.advisorVersion)) {
83
+ throw new LauncherCheckInputError("observation.advisorVersion must be a string");
84
+ }
85
+ return {
86
+ cwd: parseCwd(value.cwd),
87
+ ownerCandidates: value.ownerCandidates,
88
+ ghAvailable: value.ghAvailable,
89
+ gitAvailable: value.gitAvailable,
90
+ ...(isText(value.envOwner) ? { envOwner: value.envOwner } : {}),
91
+ ...(isText(value.advisorVersion) ? { advisorVersion: value.advisorVersion } : {}),
92
+ };
93
+ }
94
+
95
+ function unused(): CommandResult {
96
+ return { status: 1, stdout: "", stderr: "launcher-check does not run host commands" };
97
+ }
98
+
99
+ /** Host used only for owner prompting. Grading never creates a hub. */
100
+ export function planningHost(): WorkspaceHost {
101
+ return {
102
+ cwd: "/",
103
+ env: {},
104
+ isTTY: false,
105
+ now: () => "1970-01-01T00:00:00.000Z",
106
+ exists: () => false,
107
+ isDirectory: () => false,
108
+ readText: () => null,
109
+ writeText: () => {
110
+ throw new Error("launcher-check does not write");
111
+ },
112
+ mkdirp: () => {
113
+ throw new Error("launcher-check does not write");
114
+ },
115
+ readDir: () => [],
116
+ run: () => unused(),
117
+ prompt: () => null,
118
+ };
119
+ }
120
+
121
+ export function parseCheckArgs(argv: readonly string[]): { help: boolean; inputPath?: string } {
122
+ if (argv.length === 0) return { help: true };
123
+ if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) return { help: true };
124
+ if (argv.length === 2 && argv[0] === "--input" && argv[1]) return { help: false, inputPath: argv[1] };
125
+ throw new LauncherCheckInputError("expected --input <observation.json>, or --help");
126
+ }
127
+
128
+ export function checkMain(
129
+ argv: readonly string[],
130
+ readText: (path: string) => string,
131
+ writeOut: (text: string) => void,
132
+ writeErr: (text: string) => void,
133
+ ): number {
134
+ const parsed = parseCheckArgs(argv);
135
+ if (parsed.help) {
136
+ writeOut(`${CHECK_USAGE}\n`);
137
+ return 0;
138
+ }
139
+ const inputPath = parsed.inputPath;
140
+ if (inputPath === undefined) throw new LauncherCheckInputError("expected --input <observation.json>");
141
+ let raw: string;
142
+ try {
143
+ raw = readText(resolve(inputPath));
144
+ } catch {
145
+ throw new LauncherCheckInputError(`cannot read ${inputPath}`);
146
+ }
147
+ let parsedJson: unknown;
148
+ try {
149
+ parsedJson = JSON.parse(raw);
150
+ } catch {
151
+ throw new LauncherCheckInputError(`${inputPath} is not JSON`);
152
+ }
153
+ const observation = parseObservation(parsedJson);
154
+ const decision = planWorkspace(observation, planningHost());
155
+ if (decision.action === "refuse") {
156
+ writeErr(`launcher-check: ${decision.message}\n`);
157
+ return decision.state === "violated" ? 1 : 2;
158
+ }
159
+ writeOut(`${JSON.stringify({ action: decision.action, owner: decision.owner, repository: decision.repository })}\n`);
160
+ return 0;
161
+ }
162
+
163
+ if (isDirectInvocation(import.meta.url, process.argv[1])) {
164
+ try {
165
+ process.exitCode = checkMain(
166
+ process.argv.slice(2),
167
+ (path) => readFileSync(path, "utf8"),
168
+ (text) => {
169
+ process.stdout.write(text);
170
+ },
171
+ (text) => {
172
+ process.stderr.write(text);
173
+ },
174
+ );
175
+ } catch (cause) {
176
+ process.stderr.write(`launcher-check: ${cause instanceof Error ? cause.message : String(cause)}\n`);
177
+ process.exitCode = 2;
178
+ }
179
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { applyWorkspacePlan, observeWorkspace, planWorkspace, skeletonRootFromModule } from "./core.js";
6
+ import { createNodeHost } from "./host.js";
7
+ import type { WorkspaceHost } from "./types.js";
8
+
9
+ export const USAGE = `Usage: launcher [--inventory <path>]
10
+
11
+ Create, resume, or appoint a GitHub repository as the account workspace hub.
12
+
13
+ Run from an empty directory to create {owner}/workspace. Run from an existing
14
+ hub to resume. Run from any GitHub repository you want to own the account-level
15
+ hub to appoint it — it does not have to be a new exclusive repo, and it keeps
16
+ its current name and files.
17
+
18
+ Appointing requires a populated generated hub inventory (packed template
19
+ skeleton/.clossys/inventory.json; the generated path does not ship), or
20
+ --inventory <path> pointing at one. Resume does not write. Create may write
21
+ an empty inventory.
22
+
23
+ GitHub-only. Owner is inferred from \`gh\` and git remotes. Public npm reads
24
+ need no token.
25
+
26
+ Exit codes: 0 = satisfied, 1 = a known refusal, 2 = indeterminate.`;
27
+
28
+ export class LauncherInputError extends Error {}
29
+
30
+ function exitCodeFor(state: "satisfied" | "violated" | "indeterminate"): number {
31
+ return state === "satisfied" ? 0 : state === "violated" ? 1 : 2;
32
+ }
33
+
34
+ export function parseLauncherArgs(argv: readonly string[]): { help: boolean; inventoryPath?: string } {
35
+ if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) return { help: true };
36
+ if (argv.length === 0) return { help: false };
37
+ if (argv.length === 2 && argv[0] === "--inventory" && argv[1]) return { help: false, inventoryPath: argv[1] };
38
+ throw new LauncherInputError("launcher takes no arguments except optional --inventory <path>; run it from the directory to create or appoint");
39
+ }
40
+
41
+ /** Testable CLI dispatcher. Unknown arguments throw; the executable maps them to exit 2. */
42
+ export function main(argv: readonly string[], host: WorkspaceHost, skeletonRoot: string): number {
43
+ const parsed = parseLauncherArgs(argv);
44
+ if (parsed.help) {
45
+ console.log(USAGE);
46
+ return 0;
47
+ }
48
+ const observation = observeWorkspace(host);
49
+ if (
50
+ !observation.cwd.empty &&
51
+ !observation.cwd.git &&
52
+ observation.cwd.hub === undefined &&
53
+ !observation.cwd.looksLikeFoundry
54
+ ) {
55
+ console.log(USAGE);
56
+ return 0;
57
+ }
58
+ const decision = planWorkspace(observation, host, { inventoryPath: parsed.inventoryPath });
59
+ if (decision.action === "refuse") {
60
+ console.error(`launcher: ${decision.message}`);
61
+ return exitCodeFor(decision.state);
62
+ }
63
+ if (parsed.inventoryPath !== undefined && decision.action !== "adopt") {
64
+ console.error("launcher: --inventory is only valid when appointing a GitHub repository");
65
+ return 1;
66
+ }
67
+ const result = applyWorkspacePlan(host, decision, skeletonRoot);
68
+ console.log(result.message);
69
+ return 0;
70
+ }
71
+
72
+ function run(): void {
73
+ try {
74
+ const host = createNodeHost();
75
+ process.exitCode = main(process.argv.slice(2), host, skeletonRootFromModule(import.meta.url));
76
+ } catch (cause) {
77
+ console.error(`launcher: ${cause instanceof Error ? cause.message : String(cause)}`);
78
+ process.exitCode = 2;
79
+ }
80
+ }
81
+
82
+ /** Resolves an npm/POSIX bin symlink before deciding whether this module is the entrypoint. */
83
+ export function isDirectInvocation(moduleUrl: string, argvPath: string | undefined): boolean {
84
+ if (argvPath === undefined) return false;
85
+ try {
86
+ return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(resolve(argvPath));
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ if (isDirectInvocation(import.meta.url, process.argv[1])) run();