@spekn/cli 1.0.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.
- package/dist/__tests__/export-cli.test.d.ts +1 -0
- package/dist/__tests__/export-cli.test.js +70 -0
- package/dist/__tests__/tui-args-policy.test.d.ts +1 -0
- package/dist/__tests__/tui-args-policy.test.js +50 -0
- package/dist/acp-S2MHZOAD.mjs +23 -0
- package/dist/acp-UCCI44JY.mjs +25 -0
- package/dist/auth/credentials-store.d.ts +2 -0
- package/dist/auth/credentials-store.js +5 -0
- package/dist/auth/device-flow.d.ts +36 -0
- package/dist/auth/device-flow.js +189 -0
- package/dist/auth/jwt.d.ts +1 -0
- package/dist/auth/jwt.js +6 -0
- package/dist/auth/session.d.ts +67 -0
- package/dist/auth/session.js +86 -0
- package/dist/auth-login.d.ts +34 -0
- package/dist/auth-login.js +202 -0
- package/dist/auth-logout.d.ts +25 -0
- package/dist/auth-logout.js +115 -0
- package/dist/auth-status.d.ts +24 -0
- package/dist/auth-status.js +109 -0
- package/dist/backlog-generate.d.ts +11 -0
- package/dist/backlog-generate.js +308 -0
- package/dist/backlog-health.d.ts +11 -0
- package/dist/backlog-health.js +287 -0
- package/dist/bridge-login.d.ts +40 -0
- package/dist/bridge-login.js +277 -0
- package/dist/chunk-3PAYRI4G.mjs +2428 -0
- package/dist/chunk-M4CS3A25.mjs +2426 -0
- package/dist/commands/auth/login.d.ts +30 -0
- package/dist/commands/auth/login.js +164 -0
- package/dist/commands/auth/logout.d.ts +25 -0
- package/dist/commands/auth/logout.js +115 -0
- package/dist/commands/auth/status.d.ts +24 -0
- package/dist/commands/auth/status.js +109 -0
- package/dist/commands/backlog/generate.d.ts +11 -0
- package/dist/commands/backlog/generate.js +308 -0
- package/dist/commands/backlog/health.d.ts +11 -0
- package/dist/commands/backlog/health.js +287 -0
- package/dist/commands/bridge/login.d.ts +36 -0
- package/dist/commands/bridge/login.js +258 -0
- package/dist/commands/export.d.ts +35 -0
- package/dist/commands/export.js +485 -0
- package/dist/commands/marketplace-export.d.ts +21 -0
- package/dist/commands/marketplace-export.js +214 -0
- package/dist/commands/project-clean.d.ts +1 -0
- package/dist/commands/project-clean.js +126 -0
- package/dist/commands/repo/common.d.ts +105 -0
- package/dist/commands/repo/common.js +775 -0
- package/dist/commands/repo/detach.d.ts +2 -0
- package/dist/commands/repo/detach.js +120 -0
- package/dist/commands/repo/register.d.ts +21 -0
- package/dist/commands/repo/register.js +175 -0
- package/dist/commands/repo/sync.d.ts +22 -0
- package/dist/commands/repo/sync.js +873 -0
- package/dist/commands/skills-import-local.d.ts +16 -0
- package/dist/commands/skills-import-local.js +352 -0
- package/dist/commands/spec/drift-check.d.ts +3 -0
- package/dist/commands/spec/drift-check.js +186 -0
- package/dist/commands/spec/frontmatter.d.ts +11 -0
- package/dist/commands/spec/frontmatter.js +219 -0
- package/dist/commands/spec/lint.d.ts +11 -0
- package/dist/commands/spec/lint.js +499 -0
- package/dist/commands/spec/parse.d.ts +11 -0
- package/dist/commands/spec/parse.js +162 -0
- package/dist/export.d.ts +35 -0
- package/dist/export.js +485 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +21 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +115280 -0
- package/dist/marketplace-export.d.ts +21 -0
- package/dist/marketplace-export.js +214 -0
- package/dist/project-clean.d.ts +1 -0
- package/dist/project-clean.js +126 -0
- package/dist/project-context.d.ts +99 -0
- package/dist/project-context.js +376 -0
- package/dist/repo-common.d.ts +101 -0
- package/dist/repo-common.js +671 -0
- package/dist/repo-detach.d.ts +2 -0
- package/dist/repo-detach.js +102 -0
- package/dist/repo-ingest.d.ts +29 -0
- package/dist/repo-ingest.js +305 -0
- package/dist/repo-register.d.ts +21 -0
- package/dist/repo-register.js +175 -0
- package/dist/repo-sync.d.ts +16 -0
- package/dist/repo-sync.js +152 -0
- package/dist/resources/prompt-loader.d.ts +1 -0
- package/dist/resources/prompt-loader.js +62 -0
- package/dist/resources/prompts/README.md +21 -0
- package/dist/resources/prompts/prompts/repo-analysis.prompt.md +126 -0
- package/dist/resources/prompts/repo-analysis.prompt.md +151 -0
- package/dist/resources/prompts/repo-sync-analysis.prompt.md +85 -0
- package/dist/skills-import-local.d.ts +16 -0
- package/dist/skills-import-local.js +352 -0
- package/dist/spec-drift-check.d.ts +3 -0
- package/dist/spec-drift-check.js +186 -0
- package/dist/spec-frontmatter.d.ts +11 -0
- package/dist/spec-frontmatter.js +219 -0
- package/dist/spec-lint.d.ts +11 -0
- package/dist/spec-lint.js +499 -0
- package/dist/spec-parse.d.ts +11 -0
- package/dist/spec-parse.js +162 -0
- package/dist/stubs/dotenv.d.ts +5 -0
- package/dist/stubs/dotenv.js +6 -0
- package/dist/stubs/typeorm.d.ts +22 -0
- package/dist/stubs/typeorm.js +28 -0
- package/dist/tui/app.d.ts +7 -0
- package/dist/tui/app.js +122 -0
- package/dist/tui/args.d.ts +8 -0
- package/dist/tui/args.js +57 -0
- package/dist/tui/capabilities/policy.d.ts +7 -0
- package/dist/tui/capabilities/policy.js +64 -0
- package/dist/tui/components/frame.d.ts +8 -0
- package/dist/tui/components/frame.js +8 -0
- package/dist/tui/components/status-bar.d.ts +8 -0
- package/dist/tui/components/status-bar.js +8 -0
- package/dist/tui/index.d.ts +2 -0
- package/dist/tui/index.js +23 -0
- package/dist/tui/index.mjs +7563 -0
- package/dist/tui/keymap/use-global-keymap.d.ts +19 -0
- package/dist/tui/keymap/use-global-keymap.js +82 -0
- package/dist/tui/navigation/nav-items.d.ts +3 -0
- package/dist/tui/navigation/nav-items.js +18 -0
- package/dist/tui/screens/bridge.d.ts +8 -0
- package/dist/tui/screens/bridge.js +19 -0
- package/dist/tui/screens/decisions.d.ts +5 -0
- package/dist/tui/screens/decisions.js +28 -0
- package/dist/tui/screens/export.d.ts +5 -0
- package/dist/tui/screens/export.js +16 -0
- package/dist/tui/screens/home.d.ts +5 -0
- package/dist/tui/screens/home.js +33 -0
- package/dist/tui/screens/locked.d.ts +5 -0
- package/dist/tui/screens/locked.js +9 -0
- package/dist/tui/screens/specs.d.ts +5 -0
- package/dist/tui/screens/specs.js +31 -0
- package/dist/tui/services/client.d.ts +1 -0
- package/dist/tui/services/client.js +18 -0
- package/dist/tui/services/context-service.d.ts +19 -0
- package/dist/tui/services/context-service.js +246 -0
- package/dist/tui/shared-enums.d.ts +16 -0
- package/dist/tui/shared-enums.js +19 -0
- package/dist/tui/state/use-app-state.d.ts +35 -0
- package/dist/tui/state/use-app-state.js +177 -0
- package/dist/tui/types.d.ts +77 -0
- package/dist/tui/types.js +2 -0
- package/dist/tui-bundle.d.ts +1 -0
- package/dist/tui-bundle.js +5 -0
- package/dist/tui-entry.mjs +1407 -0
- package/dist/utils/cli-runtime.d.ts +5 -0
- package/dist/utils/cli-runtime.js +22 -0
- package/dist/utils/help-error.d.ts +7 -0
- package/dist/utils/help-error.js +14 -0
- package/dist/utils/interaction.d.ts +19 -0
- package/dist/utils/interaction.js +93 -0
- package/dist/utils/structured-log.d.ts +7 -0
- package/dist/utils/structured-log.js +112 -0
- package/dist/utils/trpc-url.d.ts +4 -0
- package/dist/utils/trpc-url.js +15 -0
- package/package.json +59 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
+
const export_1 = require("../export");
|
|
8
|
+
function createDeps() {
|
|
9
|
+
const state = {
|
|
10
|
+
stdout: '',
|
|
11
|
+
stderr: '',
|
|
12
|
+
writes: [],
|
|
13
|
+
};
|
|
14
|
+
const deps = {
|
|
15
|
+
createClient: () => ({
|
|
16
|
+
export: {
|
|
17
|
+
generate: {
|
|
18
|
+
mutate: async () => ({
|
|
19
|
+
content: 'GENERATED CONTENT\n',
|
|
20
|
+
anchorCount: 2,
|
|
21
|
+
}),
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
writeFile: (filePath, content) => {
|
|
26
|
+
state.writes.push({ path: filePath, content });
|
|
27
|
+
},
|
|
28
|
+
stdout: (content) => {
|
|
29
|
+
state.stdout += content;
|
|
30
|
+
},
|
|
31
|
+
stderr: (content) => {
|
|
32
|
+
state.stderr += content;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
return { state, deps };
|
|
36
|
+
}
|
|
37
|
+
async function testStdoutMode() {
|
|
38
|
+
const { state, deps } = createDeps();
|
|
39
|
+
const code = await (0, export_1.runExportCli)(['--project', '11111111-1111-4111-8111-111111111111', '--format', 'claude-md'], deps);
|
|
40
|
+
strict_1.default.equal(code, 0);
|
|
41
|
+
strict_1.default.equal(state.stdout, 'GENERATED CONTENT\n');
|
|
42
|
+
strict_1.default.equal(state.writes.length, 0);
|
|
43
|
+
}
|
|
44
|
+
async function testOutputMode() {
|
|
45
|
+
const { state, deps } = createDeps();
|
|
46
|
+
const code = await (0, export_1.runExportCli)([
|
|
47
|
+
'--project',
|
|
48
|
+
'11111111-1111-4111-8111-111111111111',
|
|
49
|
+
'--format',
|
|
50
|
+
'cursorrules',
|
|
51
|
+
'--output',
|
|
52
|
+
'tmp-export.txt',
|
|
53
|
+
], deps);
|
|
54
|
+
strict_1.default.equal(code, 0);
|
|
55
|
+
strict_1.default.equal(state.writes.length, 1);
|
|
56
|
+
strict_1.default.ok(state.stdout.includes('Exported 2 anchors'));
|
|
57
|
+
}
|
|
58
|
+
async function testMissingRequiredArgs() {
|
|
59
|
+
const { state, deps } = createDeps();
|
|
60
|
+
const code = await (0, export_1.runExportCli)(['--format', 'claude-md'], deps);
|
|
61
|
+
strict_1.default.equal(code, 1);
|
|
62
|
+
strict_1.default.ok(state.stderr.includes('Missing required argument'));
|
|
63
|
+
}
|
|
64
|
+
async function main() {
|
|
65
|
+
await testStdoutMode();
|
|
66
|
+
await testOutputMode();
|
|
67
|
+
await testMissingRequiredArgs();
|
|
68
|
+
process.stdout.write('export-cli.test.ts passed\n');
|
|
69
|
+
}
|
|
70
|
+
void main();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
7
|
+
const shared_1 = require("@spekn/shared");
|
|
8
|
+
const args_1 = require("../tui/args");
|
|
9
|
+
const policy_1 = require("../tui/capabilities/policy");
|
|
10
|
+
function testParseArgs() {
|
|
11
|
+
const parsed = (0, args_1.parseTuiArgs)([
|
|
12
|
+
'--project-id',
|
|
13
|
+
'11111111-1111-4111-8111-111111111111',
|
|
14
|
+
'--api-url',
|
|
15
|
+
'http://localhost:9999',
|
|
16
|
+
'--view',
|
|
17
|
+
'export',
|
|
18
|
+
'--no-color',
|
|
19
|
+
]);
|
|
20
|
+
strict_1.default.equal(parsed.projectId, '11111111-1111-4111-8111-111111111111');
|
|
21
|
+
strict_1.default.equal(parsed.apiUrl, 'http://localhost:9999');
|
|
22
|
+
strict_1.default.equal(parsed.initialView, 'export');
|
|
23
|
+
strict_1.default.equal(parsed.noColor, true);
|
|
24
|
+
}
|
|
25
|
+
function testTierLocks() {
|
|
26
|
+
const freePolicy = (0, policy_1.resolveNavPolicy)({
|
|
27
|
+
plan: shared_1.OrganizationPlan.FREE,
|
|
28
|
+
role: 'member',
|
|
29
|
+
workflowPhase: null,
|
|
30
|
+
permissions: [],
|
|
31
|
+
});
|
|
32
|
+
const bridge = freePolicy.find((item) => item.id === 'bridge');
|
|
33
|
+
const teamFeature = freePolicy.find((item) => item.id === 'active-runs');
|
|
34
|
+
strict_1.default.equal(bridge?.state, 'locked');
|
|
35
|
+
strict_1.default.equal(teamFeature?.state, 'locked');
|
|
36
|
+
const teamPolicy = (0, policy_1.resolveNavPolicy)({
|
|
37
|
+
plan: shared_1.OrganizationPlan.TEAM,
|
|
38
|
+
role: 'viewer',
|
|
39
|
+
workflowPhase: 'plan',
|
|
40
|
+
permissions: [],
|
|
41
|
+
});
|
|
42
|
+
const gates = teamPolicy.find((item) => item.id === 'phase-gates');
|
|
43
|
+
strict_1.default.equal(gates?.state, 'disabled');
|
|
44
|
+
}
|
|
45
|
+
function main() {
|
|
46
|
+
testParseArgs();
|
|
47
|
+
testTierLocks();
|
|
48
|
+
process.stdout.write('tui-args-policy.test.ts passed\n');
|
|
49
|
+
}
|
|
50
|
+
main();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Bundled TUI entry (ESM) — loaded via dynamic import() from CJS main.js
|
|
2
|
+
import {
|
|
3
|
+
AGENT_METHODS,
|
|
4
|
+
AgentSideConnection,
|
|
5
|
+
CLIENT_METHODS,
|
|
6
|
+
ClientSideConnection,
|
|
7
|
+
PROTOCOL_VERSION,
|
|
8
|
+
RequestError,
|
|
9
|
+
TerminalHandle,
|
|
10
|
+
init_acp,
|
|
11
|
+
ndJsonStream
|
|
12
|
+
} from "./chunk-M4CS3A25.mjs";
|
|
13
|
+
init_acp();
|
|
14
|
+
export {
|
|
15
|
+
AGENT_METHODS,
|
|
16
|
+
AgentSideConnection,
|
|
17
|
+
CLIENT_METHODS,
|
|
18
|
+
ClientSideConnection,
|
|
19
|
+
PROTOCOL_VERSION,
|
|
20
|
+
RequestError,
|
|
21
|
+
TerminalHandle,
|
|
22
|
+
ndJsonStream
|
|
23
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Bundled TUI entry (ESM) — loaded via dynamic import() from CJS main.js
|
|
2
|
+
import { createRequire as __createRequire } from 'module';
|
|
3
|
+
const require = __createRequire(import.meta.url);
|
|
4
|
+
import {
|
|
5
|
+
AGENT_METHODS,
|
|
6
|
+
AgentSideConnection,
|
|
7
|
+
CLIENT_METHODS,
|
|
8
|
+
ClientSideConnection,
|
|
9
|
+
PROTOCOL_VERSION,
|
|
10
|
+
RequestError,
|
|
11
|
+
TerminalHandle,
|
|
12
|
+
init_acp,
|
|
13
|
+
ndJsonStream
|
|
14
|
+
} from "./chunk-3PAYRI4G.mjs";
|
|
15
|
+
init_acp();
|
|
16
|
+
export {
|
|
17
|
+
AGENT_METHODS,
|
|
18
|
+
AgentSideConnection,
|
|
19
|
+
CLIENT_METHODS,
|
|
20
|
+
ClientSideConnection,
|
|
21
|
+
PROTOCOL_VERSION,
|
|
22
|
+
RequestError,
|
|
23
|
+
TerminalHandle,
|
|
24
|
+
ndJsonStream
|
|
25
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CredentialsStore = void 0;
|
|
4
|
+
var shared_1 = require("@spekn/shared");
|
|
5
|
+
Object.defineProperty(exports, "CredentialsStore", { enumerable: true, get: function () { return shared_1.CredentialsStore; } });
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 8628 Device Authorization Grant (Device Flow)
|
|
3
|
+
*
|
|
4
|
+
* Implements the OAuth 2.0 Device Authorization Grant for CLI authentication
|
|
5
|
+
* against a Keycloak realm.
|
|
6
|
+
*
|
|
7
|
+
* @see https://www.rfc-editor.org/rfc/rfc8628
|
|
8
|
+
*/
|
|
9
|
+
export interface DeviceFlowResult {
|
|
10
|
+
accessToken: string;
|
|
11
|
+
refreshToken: string;
|
|
12
|
+
expiresIn: number;
|
|
13
|
+
idToken?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface DeviceFlowDeps {
|
|
16
|
+
stdout: {
|
|
17
|
+
write(s: string): void;
|
|
18
|
+
};
|
|
19
|
+
stderr: {
|
|
20
|
+
write(s: string): void;
|
|
21
|
+
};
|
|
22
|
+
openBrowser: (url: string) => Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
/** Default browser opener — dynamically imports the `open` package. */
|
|
25
|
+
declare const defaultOpenBrowser: (url: string) => Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Perform the RFC 8628 Device Authorization Grant against a Keycloak realm.
|
|
28
|
+
*
|
|
29
|
+
* @param keycloakUrl - Base Keycloak URL, e.g. `https://auth.example.com`
|
|
30
|
+
* @param realm - Keycloak realm name
|
|
31
|
+
* @param clientId - OAuth2 client ID (must be a public client)
|
|
32
|
+
* @param deps - Optional dependency overrides for testing / custom I/O
|
|
33
|
+
* @returns Resolved tokens once the user authorises the device
|
|
34
|
+
*/
|
|
35
|
+
export declare function performDeviceFlow(keycloakUrl: string, realm: string, clientId: string, deps?: Partial<DeviceFlowDeps>): Promise<DeviceFlowResult>;
|
|
36
|
+
export { defaultOpenBrowser };
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* RFC 8628 Device Authorization Grant (Device Flow)
|
|
4
|
+
*
|
|
5
|
+
* Implements the OAuth 2.0 Device Authorization Grant for CLI authentication
|
|
6
|
+
* against a Keycloak realm.
|
|
7
|
+
*
|
|
8
|
+
* @see https://www.rfc-editor.org/rfc/rfc8628
|
|
9
|
+
*/
|
|
10
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
13
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
14
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
15
|
+
}
|
|
16
|
+
Object.defineProperty(o, k2, desc);
|
|
17
|
+
}) : (function(o, m, k, k2) {
|
|
18
|
+
if (k2 === undefined) k2 = k;
|
|
19
|
+
o[k2] = m[k];
|
|
20
|
+
}));
|
|
21
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
22
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
23
|
+
}) : function(o, v) {
|
|
24
|
+
o["default"] = v;
|
|
25
|
+
});
|
|
26
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
27
|
+
var ownKeys = function(o) {
|
|
28
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
29
|
+
var ar = [];
|
|
30
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
31
|
+
return ar;
|
|
32
|
+
};
|
|
33
|
+
return ownKeys(o);
|
|
34
|
+
};
|
|
35
|
+
return function (mod) {
|
|
36
|
+
if (mod && mod.__esModule) return mod;
|
|
37
|
+
var result = {};
|
|
38
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
39
|
+
__setModuleDefault(result, mod);
|
|
40
|
+
return result;
|
|
41
|
+
};
|
|
42
|
+
})();
|
|
43
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
exports.defaultOpenBrowser = void 0;
|
|
45
|
+
exports.performDeviceFlow = performDeviceFlow;
|
|
46
|
+
const zod_1 = require("zod");
|
|
47
|
+
const DeviceAuthorizationResponseSchema = zod_1.z.object({
|
|
48
|
+
device_code: zod_1.z.string(),
|
|
49
|
+
user_code: zod_1.z.string(),
|
|
50
|
+
verification_uri: zod_1.z.string(),
|
|
51
|
+
verification_uri_complete: zod_1.z.string(),
|
|
52
|
+
expires_in: zod_1.z.number(),
|
|
53
|
+
interval: zod_1.z.number(),
|
|
54
|
+
});
|
|
55
|
+
const TokenSuccessResponseSchema = zod_1.z.object({
|
|
56
|
+
access_token: zod_1.z.string(),
|
|
57
|
+
refresh_token: zod_1.z.string(),
|
|
58
|
+
expires_in: zod_1.z.number(),
|
|
59
|
+
id_token: zod_1.z.string().optional(),
|
|
60
|
+
token_type: zod_1.z.string(),
|
|
61
|
+
});
|
|
62
|
+
const TokenErrorResponseSchema = zod_1.z.object({
|
|
63
|
+
error: zod_1.z.string(),
|
|
64
|
+
error_description: zod_1.z.string().optional(),
|
|
65
|
+
});
|
|
66
|
+
/** Default browser opener — dynamically imports the `open` package. */
|
|
67
|
+
const defaultOpenBrowser = async (url) => {
|
|
68
|
+
const { default: open } = await Promise.resolve().then(() => __importStar(require("open")));
|
|
69
|
+
await open(url);
|
|
70
|
+
};
|
|
71
|
+
exports.defaultOpenBrowser = defaultOpenBrowser;
|
|
72
|
+
const defaultDeps = {
|
|
73
|
+
stdout: process.stdout,
|
|
74
|
+
stderr: process.stderr,
|
|
75
|
+
openBrowser: defaultOpenBrowser,
|
|
76
|
+
};
|
|
77
|
+
/** Resolves deps, filling in defaults for any omitted fields. */
|
|
78
|
+
function resolveDeps(deps) {
|
|
79
|
+
return {
|
|
80
|
+
stdout: deps?.stdout ?? defaultDeps.stdout,
|
|
81
|
+
stderr: deps?.stderr ?? defaultDeps.stderr,
|
|
82
|
+
openBrowser: deps?.openBrowser ?? defaultDeps.openBrowser,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Pause for `ms` milliseconds. */
|
|
86
|
+
function sleep(ms) {
|
|
87
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Perform the RFC 8628 Device Authorization Grant against a Keycloak realm.
|
|
91
|
+
*
|
|
92
|
+
* @param keycloakUrl - Base Keycloak URL, e.g. `https://auth.example.com`
|
|
93
|
+
* @param realm - Keycloak realm name
|
|
94
|
+
* @param clientId - OAuth2 client ID (must be a public client)
|
|
95
|
+
* @param deps - Optional dependency overrides for testing / custom I/O
|
|
96
|
+
* @returns Resolved tokens once the user authorises the device
|
|
97
|
+
*/
|
|
98
|
+
async function performDeviceFlow(keycloakUrl, realm, clientId, deps) {
|
|
99
|
+
const { stdout, stderr, openBrowser } = resolveDeps(deps);
|
|
100
|
+
// -------------------------------------------------------------------------
|
|
101
|
+
// Step 1: Request device code
|
|
102
|
+
// -------------------------------------------------------------------------
|
|
103
|
+
const deviceAuthUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/auth/device`;
|
|
104
|
+
const deviceAuthBody = new URLSearchParams({
|
|
105
|
+
client_id: clientId,
|
|
106
|
+
scope: "openid email profile offline_access spekn-api",
|
|
107
|
+
});
|
|
108
|
+
const deviceAuthResponse = await fetch(deviceAuthUrl, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
111
|
+
body: deviceAuthBody.toString(),
|
|
112
|
+
});
|
|
113
|
+
if (!deviceAuthResponse.ok) {
|
|
114
|
+
const text = await deviceAuthResponse.text();
|
|
115
|
+
throw new Error(`Device authorization request failed (${deviceAuthResponse.status}): ${text}`);
|
|
116
|
+
}
|
|
117
|
+
const deviceAuth = DeviceAuthorizationResponseSchema.parse(await deviceAuthResponse.json());
|
|
118
|
+
const { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval, } = deviceAuth;
|
|
119
|
+
// -------------------------------------------------------------------------
|
|
120
|
+
// Step 2: Display instructions to the user
|
|
121
|
+
// -------------------------------------------------------------------------
|
|
122
|
+
stdout.write(`\nTo sign in, open this URL in your browser:\n` +
|
|
123
|
+
` ${verification_uri_complete}\n\n` +
|
|
124
|
+
`Enter code: ${user_code}\n\n` +
|
|
125
|
+
`Waiting for authorization...\n`);
|
|
126
|
+
// -------------------------------------------------------------------------
|
|
127
|
+
// Step 3: Open browser (best-effort)
|
|
128
|
+
// -------------------------------------------------------------------------
|
|
129
|
+
try {
|
|
130
|
+
await openBrowser(verification_uri_complete);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
// Silently ignore browser-open failures; user still has the URL above.
|
|
134
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
135
|
+
stderr.write(`(Could not open browser automatically: ${message})\n`);
|
|
136
|
+
}
|
|
137
|
+
// -------------------------------------------------------------------------
|
|
138
|
+
// Step 4: Poll token endpoint
|
|
139
|
+
// -------------------------------------------------------------------------
|
|
140
|
+
const tokenUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`;
|
|
141
|
+
const deadline = Date.now() + expires_in * 1000;
|
|
142
|
+
// RFC 8628 §3.5: default interval is 5 seconds when not specified.
|
|
143
|
+
let pollInterval = (interval ?? 5) * 1000;
|
|
144
|
+
while (Date.now() < deadline) {
|
|
145
|
+
await sleep(pollInterval);
|
|
146
|
+
const tokenBody = new URLSearchParams({
|
|
147
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
148
|
+
device_code,
|
|
149
|
+
client_id: clientId,
|
|
150
|
+
});
|
|
151
|
+
const tokenResponse = await fetch(tokenUrl, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
154
|
+
body: tokenBody.toString(),
|
|
155
|
+
});
|
|
156
|
+
if (tokenResponse.ok) {
|
|
157
|
+
// Success — return tokens.
|
|
158
|
+
const token = TokenSuccessResponseSchema.parse(await tokenResponse.json());
|
|
159
|
+
return {
|
|
160
|
+
accessToken: token.access_token,
|
|
161
|
+
refreshToken: token.refresh_token,
|
|
162
|
+
expiresIn: token.expires_in,
|
|
163
|
+
idToken: token.id_token,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// Non-200 responses carry an RFC 8628 error code in the JSON body.
|
|
167
|
+
const errorBody = TokenErrorResponseSchema.parse(await tokenResponse.json());
|
|
168
|
+
const errorCode = errorBody.error;
|
|
169
|
+
switch (errorCode) {
|
|
170
|
+
case "authorization_pending":
|
|
171
|
+
// User has not yet authorised — keep polling.
|
|
172
|
+
break;
|
|
173
|
+
case "slow_down":
|
|
174
|
+
// RFC 8628 §3.5: increase interval by 5 seconds and continue.
|
|
175
|
+
pollInterval += 5000;
|
|
176
|
+
break;
|
|
177
|
+
case "expired_token":
|
|
178
|
+
throw new Error("Device code expired. Please try again.");
|
|
179
|
+
case "access_denied":
|
|
180
|
+
throw new Error("Authorization denied by user.");
|
|
181
|
+
default:
|
|
182
|
+
throw new Error(`Unexpected token error "${errorCode}": ${errorBody.error_description ?? "no description"}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// -------------------------------------------------------------------------
|
|
186
|
+
// Step 5: Timeout — device code has expired without user action.
|
|
187
|
+
// -------------------------------------------------------------------------
|
|
188
|
+
throw new Error(`Authorization timed out after ${expires_in} seconds. Please try again.`);
|
|
189
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { decodeJwtPayload, extractUserIdentity } from "@spekn/shared";
|
package/dist/auth/jwt.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.extractUserIdentity = exports.decodeJwtPayload = void 0;
|
|
4
|
+
var shared_1 = require("@spekn/shared");
|
|
5
|
+
Object.defineProperty(exports, "decodeJwtPayload", { enumerable: true, get: function () { return shared_1.decodeJwtPayload; } });
|
|
6
|
+
Object.defineProperty(exports, "extractUserIdentity", { enumerable: true, get: function () { return shared_1.extractUserIdentity; } });
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { CliCredentials } from "./credentials-store.js";
|
|
2
|
+
import type { CredentialsStore } from "./credentials-store.js";
|
|
3
|
+
import type { DeviceFlowDeps, DeviceFlowResult } from "./device-flow.js";
|
|
4
|
+
interface BuildCredentialsInput {
|
|
5
|
+
result: DeviceFlowResult;
|
|
6
|
+
keycloakUrl: string;
|
|
7
|
+
realm: string;
|
|
8
|
+
organizationId?: string;
|
|
9
|
+
}
|
|
10
|
+
interface BuildCredentialsOutput {
|
|
11
|
+
credentials: CliCredentials;
|
|
12
|
+
user: {
|
|
13
|
+
sub: string;
|
|
14
|
+
email: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Build persisted CLI credentials from a successful Keycloak device-flow result.
|
|
20
|
+
*/
|
|
21
|
+
export declare function buildCredentialsFromDeviceFlow(input: BuildCredentialsInput): BuildCredentialsOutput;
|
|
22
|
+
export interface EnsureAuthenticatedInput {
|
|
23
|
+
keycloakUrl: string;
|
|
24
|
+
realm: string;
|
|
25
|
+
credentialsStore: CredentialsStore;
|
|
26
|
+
performDeviceFlow: (keycloakUrl: string, realm: string, clientId: string, deps?: Partial<DeviceFlowDeps>) => Promise<DeviceFlowResult>;
|
|
27
|
+
stdout: {
|
|
28
|
+
write(s: string): void;
|
|
29
|
+
};
|
|
30
|
+
stderr: {
|
|
31
|
+
write(s: string): void;
|
|
32
|
+
};
|
|
33
|
+
forceDeviceFlow?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface EnsureAuthenticatedResult {
|
|
36
|
+
accessToken: string;
|
|
37
|
+
credentials: CliCredentials;
|
|
38
|
+
user: {
|
|
39
|
+
sub: string;
|
|
40
|
+
email: string;
|
|
41
|
+
name?: string;
|
|
42
|
+
};
|
|
43
|
+
source: "existing" | "device_flow";
|
|
44
|
+
}
|
|
45
|
+
export interface ResolveOrganizationIdInput {
|
|
46
|
+
existingOrganizationId?: string;
|
|
47
|
+
envOrganizationId?: string;
|
|
48
|
+
fetchOrganizations: () => Promise<Array<{
|
|
49
|
+
id: string;
|
|
50
|
+
name: string;
|
|
51
|
+
}>>;
|
|
52
|
+
stdout?: {
|
|
53
|
+
write(s: string): void;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Ensure CLI authentication is available.
|
|
58
|
+
* - Reuses existing valid token by default.
|
|
59
|
+
* - Runs device flow when forced or when no valid token exists.
|
|
60
|
+
*/
|
|
61
|
+
export declare function ensureAuthenticated(input: EnsureAuthenticatedInput): Promise<EnsureAuthenticatedResult>;
|
|
62
|
+
/**
|
|
63
|
+
* Resolve effective organization context:
|
|
64
|
+
* stored credentials first, then env, then API discovery fallback.
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolveOrganizationId(input: ResolveOrganizationIdInput): Promise<string | undefined>;
|
|
67
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildCredentialsFromDeviceFlow = buildCredentialsFromDeviceFlow;
|
|
4
|
+
exports.ensureAuthenticated = ensureAuthenticated;
|
|
5
|
+
exports.resolveOrganizationId = resolveOrganizationId;
|
|
6
|
+
const jwt_js_1 = require("./jwt.js");
|
|
7
|
+
/**
|
|
8
|
+
* Build persisted CLI credentials from a successful Keycloak device-flow result.
|
|
9
|
+
*/
|
|
10
|
+
function buildCredentialsFromDeviceFlow(input) {
|
|
11
|
+
const claims = (0, jwt_js_1.decodeJwtPayload)(input.result.accessToken);
|
|
12
|
+
const user = (0, jwt_js_1.extractUserIdentity)(claims);
|
|
13
|
+
const credentials = {
|
|
14
|
+
accessToken: input.result.accessToken,
|
|
15
|
+
refreshToken: input.result.refreshToken,
|
|
16
|
+
expiresAt: Date.now() + input.result.expiresIn * 1000,
|
|
17
|
+
keycloakUrl: input.keycloakUrl,
|
|
18
|
+
realm: input.realm,
|
|
19
|
+
organizationId: input.organizationId,
|
|
20
|
+
user,
|
|
21
|
+
};
|
|
22
|
+
return { credentials, user };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Ensure CLI authentication is available.
|
|
26
|
+
* - Reuses existing valid token by default.
|
|
27
|
+
* - Runs device flow when forced or when no valid token exists.
|
|
28
|
+
*/
|
|
29
|
+
async function ensureAuthenticated(input) {
|
|
30
|
+
if (!input.forceDeviceFlow) {
|
|
31
|
+
const existingToken = await input.credentialsStore.getValidToken();
|
|
32
|
+
const existingCreds = input.credentialsStore.load();
|
|
33
|
+
if (existingToken && existingCreds) {
|
|
34
|
+
return {
|
|
35
|
+
accessToken: existingToken,
|
|
36
|
+
credentials: existingCreds,
|
|
37
|
+
user: existingCreds.user ?? {
|
|
38
|
+
sub: "unknown",
|
|
39
|
+
email: "unknown",
|
|
40
|
+
},
|
|
41
|
+
source: "existing",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const result = await input.performDeviceFlow(input.keycloakUrl, input.realm, "spekn-cli", { stdout: input.stdout, stderr: input.stderr });
|
|
46
|
+
const { credentials, user } = buildCredentialsFromDeviceFlow({
|
|
47
|
+
result,
|
|
48
|
+
keycloakUrl: input.keycloakUrl,
|
|
49
|
+
realm: input.realm,
|
|
50
|
+
});
|
|
51
|
+
input.credentialsStore.save(credentials);
|
|
52
|
+
return {
|
|
53
|
+
accessToken: result.accessToken,
|
|
54
|
+
credentials,
|
|
55
|
+
user,
|
|
56
|
+
source: "device_flow",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Resolve effective organization context:
|
|
61
|
+
* stored credentials first, then env, then API discovery fallback.
|
|
62
|
+
*/
|
|
63
|
+
async function resolveOrganizationId(input) {
|
|
64
|
+
if (input.existingOrganizationId) {
|
|
65
|
+
return input.existingOrganizationId;
|
|
66
|
+
}
|
|
67
|
+
if (input.envOrganizationId) {
|
|
68
|
+
return input.envOrganizationId;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const orgs = await input.fetchOrganizations();
|
|
72
|
+
if (orgs.length === 0)
|
|
73
|
+
return undefined;
|
|
74
|
+
if (orgs.length > 1 && input.stdout) {
|
|
75
|
+
input.stdout.write(`\nYou belong to ${orgs.length} organizations:\n`);
|
|
76
|
+
for (const org of orgs) {
|
|
77
|
+
input.stdout.write(` - ${org.name} (${org.id})\n`);
|
|
78
|
+
}
|
|
79
|
+
input.stdout.write(`\nAuto-selected first organization. Use SPEKN_ORGANIZATION_ID to override.\n`);
|
|
80
|
+
}
|
|
81
|
+
return orgs[0]?.id;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* auth login CLI command
|
|
4
|
+
*
|
|
5
|
+
* Authenticates the CLI user via the Keycloak Device Authorization Grant
|
|
6
|
+
* (RFC 8628) and persists credentials to ~/.spekn/credentials.json.
|
|
7
|
+
*
|
|
8
|
+
* Usage: spekn auth login [--keycloak-url <url>] [--realm <realm>]
|
|
9
|
+
*/
|
|
10
|
+
import type { DeviceFlowDeps, DeviceFlowResult } from "./auth/device-flow.js";
|
|
11
|
+
import { CredentialsStore } from "./auth/credentials-store.js";
|
|
12
|
+
interface CLIOptions {
|
|
13
|
+
keycloakUrl: string;
|
|
14
|
+
realm: string;
|
|
15
|
+
}
|
|
16
|
+
interface Deps {
|
|
17
|
+
stdout: {
|
|
18
|
+
write(s: string): void;
|
|
19
|
+
};
|
|
20
|
+
stderr: {
|
|
21
|
+
write(s: string): void;
|
|
22
|
+
};
|
|
23
|
+
performDeviceFlow: (keycloakUrl: string, realm: string, clientId: string, deps?: Partial<DeviceFlowDeps>) => Promise<DeviceFlowResult>;
|
|
24
|
+
credentialsStore: CredentialsStore;
|
|
25
|
+
}
|
|
26
|
+
declare function parseArgs(args: string[]): CLIOptions;
|
|
27
|
+
/**
|
|
28
|
+
* Decode the payload of a JWT access token without verification.
|
|
29
|
+
* Returns the raw claims object, or null if decoding fails.
|
|
30
|
+
*/
|
|
31
|
+
declare function decodeJwtPayload(token: string): Record<string, unknown> | null;
|
|
32
|
+
export declare function runAuthLoginCli(args: string[], deps?: Partial<Deps>): Promise<number>;
|
|
33
|
+
declare function main(): Promise<void>;
|
|
34
|
+
export { main, parseArgs, decodeJwtPayload };
|