@ductape/cli 0.2.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 (155) hide show
  1. package/API_PARITY.md +58 -0
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +89 -0
  4. package/dist/commands/apps-import.d.ts +9 -0
  5. package/dist/commands/apps-import.js +29 -0
  6. package/dist/commands/apps.d.ts +10 -0
  7. package/dist/commands/apps.js +68 -0
  8. package/dist/commands/cloud.d.ts +7 -0
  9. package/dist/commands/cloud.js +42 -0
  10. package/dist/commands/completion.d.ts +1 -0
  11. package/dist/commands/completion.js +113 -0
  12. package/dist/commands/db.d.ts +5 -0
  13. package/dist/commands/db.js +55 -0
  14. package/dist/commands/generate.d.ts +14 -0
  15. package/dist/commands/generate.js +52 -0
  16. package/dist/commands/graph.d.ts +4 -0
  17. package/dist/commands/graph.js +44 -0
  18. package/dist/commands/init.d.ts +7 -0
  19. package/dist/commands/init.js +28 -0
  20. package/dist/commands/install.d.ts +1 -0
  21. package/dist/commands/install.js +42 -0
  22. package/dist/commands/link.d.ts +7 -0
  23. package/dist/commands/link.js +61 -0
  24. package/dist/commands/login.d.ts +9 -0
  25. package/dist/commands/login.js +103 -0
  26. package/dist/commands/logout.d.ts +1 -0
  27. package/dist/commands/logout.js +6 -0
  28. package/dist/commands/platform.d.ts +7 -0
  29. package/dist/commands/platform.js +99 -0
  30. package/dist/commands/products.d.ts +14 -0
  31. package/dist/commands/products.js +74 -0
  32. package/dist/commands/profiles.d.ts +2 -0
  33. package/dist/commands/profiles.js +18 -0
  34. package/dist/commands/resources.d.ts +7 -0
  35. package/dist/commands/resources.js +61 -0
  36. package/dist/commands/secrets.d.ts +6 -0
  37. package/dist/commands/secrets.js +38 -0
  38. package/dist/commands/unlink.d.ts +3 -0
  39. package/dist/commands/unlink.js +14 -0
  40. package/dist/commands/whoami.d.ts +1 -0
  41. package/dist/commands/whoami.js +22 -0
  42. package/dist/commands/workspaces.d.ts +16 -0
  43. package/dist/commands/workspaces.js +72 -0
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.js +283 -0
  46. package/dist/lib/api-client.d.ts +18 -0
  47. package/dist/lib/api-client.js +65 -0
  48. package/dist/lib/app-import.d.ts +15 -0
  49. package/dist/lib/app-import.js +63 -0
  50. package/dist/lib/config.d.ts +63 -0
  51. package/dist/lib/config.js +155 -0
  52. package/dist/lib/context-store.d.ts +17 -0
  53. package/dist/lib/context-store.js +43 -0
  54. package/dist/lib/db-methods.d.ts +3 -0
  55. package/dist/lib/db-methods.js +55 -0
  56. package/dist/lib/encryption.d.ts +1 -0
  57. package/dist/lib/encryption.js +11 -0
  58. package/dist/lib/forms/enums.d.ts +11 -0
  59. package/dist/lib/forms/enums.js +42 -0
  60. package/dist/lib/forms/index.d.ts +2 -0
  61. package/dist/lib/forms/index.js +2 -0
  62. package/dist/lib/forms/prompt.d.ts +11 -0
  63. package/dist/lib/forms/prompt.js +203 -0
  64. package/dist/lib/forms/schemas.d.ts +9 -0
  65. package/dist/lib/forms/schemas.js +253 -0
  66. package/dist/lib/forms/types.d.ts +18 -0
  67. package/dist/lib/forms/types.js +1 -0
  68. package/dist/lib/http.d.ts +10 -0
  69. package/dist/lib/http.js +51 -0
  70. package/dist/lib/integrations.d.ts +20 -0
  71. package/dist/lib/integrations.js +20 -0
  72. package/dist/lib/interactive-opts.d.ts +6 -0
  73. package/dist/lib/interactive-opts.js +6 -0
  74. package/dist/lib/output.d.ts +3 -0
  75. package/dist/lib/output.js +22 -0
  76. package/dist/lib/platform-api.d.ts +19 -0
  77. package/dist/lib/platform-api.js +123 -0
  78. package/dist/lib/proxy/context.d.ts +16 -0
  79. package/dist/lib/proxy/context.js +73 -0
  80. package/dist/lib/proxy/db-proxy.d.ts +13 -0
  81. package/dist/lib/proxy/db-proxy.js +28 -0
  82. package/dist/lib/proxy/graph-proxy.d.ts +19 -0
  83. package/dist/lib/proxy/graph-proxy.js +43 -0
  84. package/dist/lib/proxy/sdk-proxy.d.ts +14 -0
  85. package/dist/lib/proxy/sdk-proxy.js +22 -0
  86. package/dist/lib/read-body.d.ts +11 -0
  87. package/dist/lib/read-body.js +38 -0
  88. package/dist/lib/resources.d.ts +12 -0
  89. package/dist/lib/resources.js +147 -0
  90. package/dist/lib/snippets.d.ts +6 -0
  91. package/dist/lib/snippets.js +146 -0
  92. package/dist/lib/templates.d.ts +3 -0
  93. package/dist/lib/templates.js +74 -0
  94. package/dist/lib/workspace-context.d.ts +12 -0
  95. package/dist/lib/workspace-context.js +25 -0
  96. package/dist/lib/workspaces.d.ts +45 -0
  97. package/dist/lib/workspaces.js +219 -0
  98. package/package.json +37 -0
  99. package/src/commands/apps-import.ts +43 -0
  100. package/src/commands/apps.ts +92 -0
  101. package/src/commands/cloud.ts +50 -0
  102. package/src/commands/completion.ts +119 -0
  103. package/src/commands/db.ts +63 -0
  104. package/src/commands/generate.ts +74 -0
  105. package/src/commands/graph.ts +51 -0
  106. package/src/commands/init.ts +39 -0
  107. package/src/commands/install.ts +48 -0
  108. package/src/commands/link.ts +84 -0
  109. package/src/commands/login.ts +145 -0
  110. package/src/commands/logout.ts +7 -0
  111. package/src/commands/platform.ts +123 -0
  112. package/src/commands/products.ts +104 -0
  113. package/src/commands/profiles.ts +23 -0
  114. package/src/commands/resources.ts +85 -0
  115. package/src/commands/secrets.ts +46 -0
  116. package/src/commands/unlink.ts +15 -0
  117. package/src/commands/whoami.ts +27 -0
  118. package/src/commands/workspaces.ts +98 -0
  119. package/src/index.ts +381 -0
  120. package/src/lib/api-client.ts +88 -0
  121. package/src/lib/app-import.ts +82 -0
  122. package/src/lib/config.ts +225 -0
  123. package/src/lib/context-store.ts +77 -0
  124. package/src/lib/db-methods.ts +56 -0
  125. package/src/lib/encryption.ts +12 -0
  126. package/src/lib/forms/enums.ts +52 -0
  127. package/src/lib/forms/index.ts +8 -0
  128. package/src/lib/forms/prompt.ts +209 -0
  129. package/src/lib/forms/schemas.ts +288 -0
  130. package/src/lib/forms/types.ts +27 -0
  131. package/src/lib/http.ts +70 -0
  132. package/src/lib/integrations.ts +47 -0
  133. package/src/lib/interactive-opts.ts +13 -0
  134. package/src/lib/output.ts +24 -0
  135. package/src/lib/platform-api.ts +178 -0
  136. package/src/lib/proxy/context.ts +94 -0
  137. package/src/lib/proxy/db-proxy.ts +40 -0
  138. package/src/lib/proxy/graph-proxy.ts +64 -0
  139. package/src/lib/proxy/sdk-proxy.ts +58 -0
  140. package/src/lib/read-body.ts +57 -0
  141. package/src/lib/resources.ts +156 -0
  142. package/src/lib/snippets.ts +165 -0
  143. package/src/lib/templates.ts +78 -0
  144. package/src/lib/workspace-context.ts +43 -0
  145. package/src/lib/workspaces.ts +283 -0
  146. package/templates/dotnet/Program.cs +2 -0
  147. package/templates/go/main.go +8 -0
  148. package/templates/python/ductape_example.py +16 -0
  149. package/templates/typescript/ductape.example.ts +18 -0
  150. package/test/encryption.test.ts +20 -0
  151. package/test/forms.test.ts +25 -0
  152. package/test/platform-api.test.ts +19 -0
  153. package/test/resources.test.ts +23 -0
  154. package/test/workspace-resolve.test.ts +29 -0
  155. package/tsconfig.json +15 -0
@@ -0,0 +1,74 @@
1
+ import fs from 'node:fs';
2
+ import { requireSession } from '../lib/proxy/context.js';
3
+ import { generateExecutablePayload } from '../lib/integrations.js';
4
+ import {
5
+ buildSnippet,
6
+ ensureSupportedSnippetOperation,
7
+ } from '../lib/snippets.js';
8
+ import { printJson } from '../lib/output.js';
9
+
10
+ export async function runGeneratePayload(opts: {
11
+ family: string;
12
+ method: string;
13
+ file?: string;
14
+ json?: boolean;
15
+ }): Promise<void> {
16
+ const session = requireSession();
17
+ let targets: Record<string, unknown> | undefined;
18
+ if (opts.file) {
19
+ targets = JSON.parse(fs.readFileSync(opts.file, 'utf8')) as Record<string, unknown>;
20
+ }
21
+
22
+ const result = await generateExecutablePayload(session.apiUrl, session.credentials, {
23
+ workspace_id: session.project.workspace_id,
24
+ user_id: session.credentials.user_id,
25
+ public_key: session.credentials.public_key,
26
+ product_tag: session.project.product_tag,
27
+ env_slug: session.project.env_slug,
28
+ operation_family: opts.family,
29
+ method: opts.method,
30
+ targets,
31
+ include_session: true,
32
+ include_cache: true,
33
+ schema_mode: 'best_effort',
34
+ });
35
+
36
+ printJson(result, Boolean(opts.json));
37
+ }
38
+
39
+ export async function runGenerateSnippet(opts: {
40
+ family: string;
41
+ method: string;
42
+ language: 'typescript' | 'python' | 'go' | 'dotnet';
43
+ file?: string;
44
+ json?: boolean;
45
+ out?: string;
46
+ }): Promise<void> {
47
+ ensureSupportedSnippetOperation(opts.family, opts.method);
48
+ const session = requireSession();
49
+ let targets: Record<string, unknown> | undefined;
50
+ if (opts.file) {
51
+ targets = JSON.parse(fs.readFileSync(opts.file, 'utf8')) as Record<string, unknown>;
52
+ }
53
+
54
+ const generated = await generateExecutablePayload(session.apiUrl, session.credentials, {
55
+ workspace_id: session.project.workspace_id,
56
+ user_id: session.credentials.user_id,
57
+ public_key: session.credentials.public_key,
58
+ product_tag: session.project.product_tag,
59
+ env_slug: session.project.env_slug,
60
+ operation_family: opts.family,
61
+ method: opts.method,
62
+ targets,
63
+ });
64
+
65
+ const payload = generated.payload ?? {};
66
+ const snippet = buildSnippet(opts.language, payload, opts.family, opts.method);
67
+ const output = { payload: generated, snippet };
68
+
69
+ if (opts.out) {
70
+ fs.writeFileSync(opts.out, snippet);
71
+ console.log(`Wrote snippet to ${opts.out}`);
72
+ }
73
+ printJson(output, Boolean(opts.json));
74
+ }
@@ -0,0 +1,51 @@
1
+ import { readJsonBody } from '../lib/read-body.js';
2
+ import { setGraphContext } from '../lib/context-store.js';
3
+ import { GRAPH_PROXY_METHODS } from '../lib/db-methods.js';
4
+ import { getGraphProxy, requireSession } from '../lib/proxy/context.js';
5
+ import { printJson } from '../lib/output.js';
6
+
7
+ export async function runGraph(
8
+ method: string,
9
+ opts: { file?: string; json?: boolean },
10
+ ): Promise<void> {
11
+ const session = requireSession();
12
+ const proxy = getGraphProxy(session);
13
+ const body = readJsonBody(opts.file) as Record<string, unknown>;
14
+
15
+ const m = method.includes('.') ? method : method.toLowerCase();
16
+ if (!GRAPH_PROXY_METHODS.includes(m as (typeof GRAPH_PROXY_METHODS)[number]) && !m.includes('.')) {
17
+ console.warn(`Method "${m}" not in known list; sending anyway. Known: ${GRAPH_PROXY_METHODS.join(', ')}`);
18
+ }
19
+
20
+ const params: unknown[] = [];
21
+ if (m === 'connect') {
22
+ const cfg = {
23
+ product: session.project.product_tag,
24
+ env: session.project.env_slug,
25
+ graph: body.graph ?? body.tag,
26
+ ...body,
27
+ };
28
+ params.push(cfg);
29
+ } else if (Object.keys(body).length > 0) {
30
+ params.push({
31
+ product: session.project.product_tag,
32
+ env: session.project.env_slug,
33
+ ...body,
34
+ });
35
+ } else if (m === 'fetchAll' || m === 'list') {
36
+ params.push(session.project.product_tag);
37
+ }
38
+
39
+ const result = await proxy.execute(m === 'list' ? 'fetchAll' : m, params);
40
+
41
+ if (m === 'connect' && params[0] && typeof params[0] === 'object') {
42
+ const cfg = params[0] as { graph: string; env: string; product: string };
43
+ setGraphContext(session.project.workspace_id, session.project.product_tag, session.project.env_slug, {
44
+ graph: String(cfg.graph),
45
+ env: String(cfg.env),
46
+ product: String(cfg.product),
47
+ });
48
+ }
49
+
50
+ printJson(result, Boolean(opts.json));
51
+ }
@@ -0,0 +1,39 @@
1
+ import { saveProjectConfig, type ProjectConfig } from '../lib/config.js';
2
+ import { detectLanguage, writeInitArtifacts } from '../lib/templates.js';
3
+ import { success } from '../lib/output.js';
4
+ import { runLink } from './link.js';
5
+
6
+ export async function runInit(opts: {
7
+ link?: boolean;
8
+ workspace?: string;
9
+ product?: string;
10
+ env?: string;
11
+ language?: string;
12
+ }): Promise<void> {
13
+ const dir = process.cwd();
14
+ const lang =
15
+ opts.language && ['typescript', 'python', 'go', 'dotnet'].includes(opts.language)
16
+ ? (opts.language as 'typescript' | 'python' | 'go' | 'dotnet')
17
+ : detectLanguage(dir);
18
+
19
+ writeInitArtifacts(dir, lang);
20
+
21
+ if (!opts.link) {
22
+ const stub: ProjectConfig = {
23
+ workspace_tag: '',
24
+ product_tag: '',
25
+ env_slug: opts.env ?? 'dev',
26
+ };
27
+ saveProjectConfig(dir, stub);
28
+ success(`Initialized Ductape (${lang}). Run \`ductape link\` to connect workspace/product.`);
29
+ return;
30
+ }
31
+
32
+ await runLink({
33
+ workspace: opts.workspace,
34
+ product: opts.product,
35
+ env: opts.env,
36
+ dir,
37
+ });
38
+ success(`Initialized and linked Ductape project (${lang})`);
39
+ }
@@ -0,0 +1,48 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { resolvePlatformDir } from '../lib/config.js';
5
+ import { success } from '../lib/output.js';
6
+
7
+ function checkCommand(cmd: string, args: string[]): boolean {
8
+ const r = spawnSync(cmd, args, { stdio: 'ignore' });
9
+ return r.status === 0;
10
+ }
11
+
12
+ export function runInstall(): void {
13
+ const issues: string[] = [];
14
+
15
+ if (!checkCommand('docker', ['--version'])) {
16
+ issues.push('Docker is not installed or not on PATH');
17
+ } else if (!checkCommand('docker', ['compose', 'version'])) {
18
+ issues.push('Docker Compose v2 is required (docker compose)');
19
+ }
20
+
21
+ if (!checkCommand('node', ['--version'])) {
22
+ issues.push('Node.js 18+ is recommended for running the CLI');
23
+ }
24
+
25
+ let platformDir: string;
26
+ try {
27
+ platformDir = resolvePlatformDir();
28
+ } catch (e) {
29
+ issues.push(e instanceof Error ? e.message : String(e));
30
+ platformDir = '';
31
+ }
32
+
33
+ if (platformDir) {
34
+ const envExample = path.join(platformDir, '.env.example');
35
+ const envFile = path.join(platformDir, '.env');
36
+ if (fs.existsSync(envExample) && !fs.existsSync(envFile)) {
37
+ fs.copyFileSync(envExample, envFile);
38
+ success(`Created ${envFile} from .env.example`);
39
+ }
40
+ }
41
+
42
+ if (issues.length > 0) {
43
+ console.error('Install check failed:\n' + issues.map((i) => ` - ${i}`).join('\n'));
44
+ process.exit(1);
45
+ }
46
+
47
+ success('Prerequisites OK. Run `ductape start` to launch the local platform.');
48
+ }
@@ -0,0 +1,84 @@
1
+ import readline from 'node:readline/promises';
2
+ import { stdin as input, stdout as output } from 'node:process';
3
+ import path from 'node:path';
4
+ import {
5
+ loadCredentials,
6
+ saveProjectConfig,
7
+ type ProjectConfig,
8
+ } from '../lib/config.js';
9
+ import {
10
+ fetchWorkspaces,
11
+ findWorkspace,
12
+ getActiveWorkspace,
13
+ promptWorkspaceSelection,
14
+ setActiveWorkspace,
15
+ workspaceId,
16
+ workspaceLabel,
17
+ workspaceTag,
18
+ type WorkspaceSummary,
19
+ } from '../lib/workspaces.js';
20
+ import { success } from '../lib/output.js';
21
+
22
+ export async function runLink(opts: {
23
+ workspace?: string;
24
+ product?: string;
25
+ env?: string;
26
+ profile?: string;
27
+ dir?: string;
28
+ }): Promise<void> {
29
+ const creds = loadCredentials();
30
+ if (!creds) throw new Error('Not logged in. Run: ductape login');
31
+
32
+ const rl = readline.createInterface({ input, output });
33
+
34
+ let workspaceSummary: WorkspaceSummary;
35
+ const selector = opts.workspace?.trim();
36
+ if (!selector) {
37
+ const active = getActiveWorkspace();
38
+ if (active?.id) {
39
+ console.log(`Using active workspace: ${active.name ?? active.tag ?? active.id}`);
40
+ workspaceSummary = {
41
+ workspace_id: active.id,
42
+ workspace_name: active.name,
43
+ tag: active.tag,
44
+ };
45
+ } else {
46
+ const workspaces = await fetchWorkspaces(opts.profile);
47
+ if (workspaces.length === 0) {
48
+ throw new Error('No workspaces on this account. Create one in the Workbench first.');
49
+ } else {
50
+ workspaceSummary = await promptWorkspaceSelection(workspaces, {
51
+ message: 'Link project to workspace:',
52
+ });
53
+ }
54
+ }
55
+ } else {
56
+ const workspaces = await fetchWorkspaces(opts.profile);
57
+ const match = findWorkspace(workspaces, selector);
58
+ if (!match) {
59
+ throw new Error(`Workspace not found: ${selector}`);
60
+ }
61
+ workspaceSummary = match;
62
+ setActiveWorkspace(workspaceId(match), workspaceLabel(match), workspaceTag(match));
63
+ }
64
+
65
+ const workspaceIdValue = workspaceId(workspaceSummary);
66
+ const workspaceTagValue = workspaceTag(workspaceSummary);
67
+
68
+ const productTag = opts.product ?? (await rl.question('Product tag: '));
69
+ const envSlug = opts.env ?? (await rl.question('Environment slug (e.g. dev): '));
70
+ rl.close();
71
+
72
+ const targetDir = path.resolve(opts.dir ?? process.cwd());
73
+ const config: ProjectConfig = {
74
+ workspace_tag: workspaceTagValue,
75
+ workspace_id: workspaceIdValue,
76
+ product_tag: productTag.trim(),
77
+ env_slug: envSlug.trim(),
78
+ linked_profile: opts.profile,
79
+ };
80
+
81
+ saveProjectConfig(targetDir, config);
82
+ setActiveWorkspace(workspaceIdValue, workspaceLabel(workspaceSummary), workspaceTagValue);
83
+ success(`Linked project in ${path.join(targetDir, '.ductape/config.json')}`);
84
+ }
@@ -0,0 +1,145 @@
1
+ import inputPrompt from '@inquirer/input';
2
+ import passwordPrompt from '@inquirer/password';
3
+ import { spawn } from 'node:child_process';
4
+ import { getApiUrl, getActiveProfileName, saveCredentials, type Credentials } from '../lib/config.js';
5
+ import { parseJsonResponse } from '../lib/http.js';
6
+ import { finalizeLoginWorkspace } from '../lib/workspaces.js';
7
+ import { success } from '../lib/output.js';
8
+
9
+ interface LoginResult {
10
+ _id: string;
11
+ email: string;
12
+ firstname?: string;
13
+ lastname?: string;
14
+ auth_token: string;
15
+ public_key: string;
16
+ workspaces?: Credentials['workspaces'];
17
+ requires_verification?: boolean;
18
+ }
19
+
20
+ function saveFromResult(user: LoginResult): void {
21
+ saveCredentials({
22
+ user_id: user._id,
23
+ email: user.email,
24
+ auth_token: user.auth_token,
25
+ public_key: user.public_key,
26
+ firstname: user.firstname,
27
+ lastname: user.lastname,
28
+ workspaces: user.workspaces,
29
+ });
30
+ }
31
+
32
+ async function exchangeOAuthToken(apiUrl: string, token: string): Promise<LoginResult> {
33
+ const res = await fetch(`${apiUrl}/users/v1/auth/oauth-session`, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({ token }),
37
+ });
38
+ const body = await parseJsonResponse<{ message?: string; data?: { result?: LoginResult } & LoginResult }>(
39
+ res,
40
+ `${apiUrl}/users/v1/auth/oauth-session`,
41
+ );
42
+ if (!res.ok) throw new Error(body.message ?? `OAuth exchange failed (${res.status})`);
43
+ const user = (body.data?.result ?? body.data) as LoginResult;
44
+ if (!user?.auth_token) throw new Error(body.message ?? 'Invalid OAuth token');
45
+ return user;
46
+ }
47
+
48
+ function openBrowser(url: string): void {
49
+ const cmd =
50
+ process.platform === 'darwin'
51
+ ? 'open'
52
+ : process.platform === 'win32'
53
+ ? 'start'
54
+ : 'xdg-open';
55
+ spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
56
+ }
57
+
58
+ export async function runLogin(opts: {
59
+ email?: string;
60
+ password?: string;
61
+ profile?: string;
62
+ token?: string;
63
+ browser?: 'google' | 'github';
64
+ workspace?: string;
65
+ skipWorkspaceSelect?: boolean;
66
+ }): Promise<void> {
67
+ const profileName = opts.profile ?? getActiveProfileName();
68
+ const apiUrl = getApiUrl(opts.profile);
69
+
70
+ if (opts.token) {
71
+ const user = await exchangeOAuthToken(apiUrl, opts.token);
72
+ saveFromResult(user);
73
+ success(`Logged in as ${user.email} (OAuth)`);
74
+ await finalizeLoginWorkspace({
75
+ profile: opts.profile,
76
+ workspace: opts.workspace,
77
+ skipPrompt: opts.skipWorkspaceSelect,
78
+ });
79
+ return;
80
+ }
81
+
82
+ if (opts.browser) {
83
+ const path =
84
+ opts.browser === 'github' ? '/users/v1/auth/github' : '/users/v1/auth/google';
85
+ const authUrl = `${apiUrl}${path}`;
86
+ console.log('Opening browser for OAuth login…');
87
+ console.log(`If the browser does not open, visit:\n ${authUrl}\n`);
88
+ console.log(
89
+ 'After sign-in you will be redirected with ?loggedIn=true&token=… in the URL.\n' +
90
+ 'Copy the token value and run:\n' +
91
+ ' ductape login --token "<paste-token-here>"\n',
92
+ );
93
+ openBrowser(authUrl);
94
+ return;
95
+ }
96
+
97
+ let email = opts.email;
98
+ let password = opts.password;
99
+
100
+ if (!email || !password) {
101
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
102
+ throw new Error(
103
+ 'Interactive login requires a TTY. Pass --email and --password, or use --token / --browser.',
104
+ );
105
+ }
106
+ email = email ?? (await inputPrompt({ message: 'Email' }));
107
+ password =
108
+ password ??
109
+ (await passwordPrompt({
110
+ message: 'Password',
111
+ mask: '*',
112
+ }));
113
+ }
114
+
115
+ const loginUrl = `${apiUrl}/users/v1/login`;
116
+ const res = await fetch(loginUrl, {
117
+ method: 'POST',
118
+ headers: { 'Content-Type': 'application/json' },
119
+ body: JSON.stringify({ email, password }),
120
+ });
121
+
122
+ const body = await parseJsonResponse<{ message?: string; data?: { result?: LoginResult } & LoginResult }>(
123
+ res,
124
+ loginUrl,
125
+ );
126
+ if (!res.ok) {
127
+ throw new Error(
128
+ body.message ?? `Login failed (HTTP ${res.status}) — profile "${profileName}", ${loginUrl}`,
129
+ );
130
+ }
131
+
132
+ const user = (body.data?.result ?? body.data) as LoginResult;
133
+ if (!user?.auth_token) throw new Error(body.message ?? 'Login failed');
134
+ if (user.requires_verification) {
135
+ throw new Error('Account requires email verification. Complete verification in the Workbench first.');
136
+ }
137
+
138
+ saveFromResult(user);
139
+ success(`Logged in as ${user.email}`);
140
+ await finalizeLoginWorkspace({
141
+ profile: opts.profile,
142
+ workspace: opts.workspace,
143
+ skipPrompt: opts.skipWorkspaceSelect,
144
+ });
145
+ }
@@ -0,0 +1,7 @@
1
+ import { clearCredentials } from '../lib/config.js';
2
+ import { success } from '../lib/output.js';
3
+
4
+ export function runLogout(): void {
5
+ clearCredentials();
6
+ success('Logged out');
7
+ }
@@ -0,0 +1,123 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { getApiUrl, loadGlobalConfig, resolvePlatformDir } from '../lib/config.js';
4
+ import { printJson, success } from '../lib/output.js';
5
+
6
+ const WORKBENCH_ONLY_COMPOSE = [
7
+ '-f',
8
+ 'docker-compose.yml',
9
+ '-f',
10
+ 'docker-compose.workbench.yml',
11
+ ] as const;
12
+
13
+ function normalizeViteApiBase(url: string): string {
14
+ return url.endsWith('/') ? url : `${url}/`;
15
+ }
16
+
17
+ function runCompose(args: string[], cwd: string): Promise<number> {
18
+ return new Promise((resolve, reject) => {
19
+ const child = spawn('docker', ['compose', ...args], {
20
+ cwd,
21
+ stdio: 'inherit',
22
+ shell: process.platform === 'win32',
23
+ });
24
+ child.on('error', reject);
25
+ child.on('close', (code) => resolve(code ?? 1));
26
+ });
27
+ }
28
+
29
+ export async function runStart(opts: {
30
+ backendOnly?: boolean;
31
+ build?: boolean;
32
+ remote?: boolean;
33
+ }): Promise<void> {
34
+ const platformDir = resolvePlatformDir();
35
+
36
+ if (opts.remote) {
37
+ const apiBase = normalizeViteApiBase(getApiUrl('cloud'));
38
+ const composeArgs = [...WORKBENCH_ONLY_COMPOSE];
39
+ if (opts.build) {
40
+ const buildCode = await runCompose(
41
+ [
42
+ ...composeArgs,
43
+ 'build',
44
+ '--build-arg',
45
+ `VITE_API_BASE_URL=${apiBase}`,
46
+ '--build-arg',
47
+ 'VITE_APP_ENV=development',
48
+ 'workbench',
49
+ ],
50
+ platformDir,
51
+ );
52
+ if (buildCode !== 0) throw new Error(`docker compose build exited with ${buildCode}`);
53
+ }
54
+ const code = await runCompose([...composeArgs, 'up', '-d', 'workbench'], platformDir);
55
+ if (code !== 0) throw new Error(`docker compose exited with ${code}`);
56
+ success(`Workbench UI: http://localhost:4310 (API → ${apiBase})`);
57
+ return;
58
+ }
59
+
60
+ const composeArgs = opts.backendOnly
61
+ ? ['-f', 'docker-compose.yml', '-f', 'docker-compose.backend.yml', 'up', '-d']
62
+ : ['up', '-d'];
63
+ if (opts.build) {
64
+ const upIdx = composeArgs.indexOf('up');
65
+ composeArgs.splice(upIdx + 1, 0, '--build');
66
+ }
67
+ const code = await runCompose(composeArgs, platformDir);
68
+ if (code !== 0) throw new Error(`docker compose exited with ${code}`);
69
+ success('Ductape platform started (see platform/README.md for URLs)');
70
+ }
71
+
72
+ export async function runStop(): Promise<void> {
73
+ const platformDir = resolvePlatformDir();
74
+ const code = await runCompose(['down'], platformDir);
75
+ if (code !== 0) throw new Error(`docker compose exited with ${code}`);
76
+ success('Ductape platform stopped');
77
+ }
78
+
79
+ async function probeHealth(url: string): Promise<{
80
+ url: string;
81
+ ok: boolean;
82
+ status?: number;
83
+ error?: string;
84
+ hint?: string;
85
+ }> {
86
+ try {
87
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
88
+ const hint =
89
+ res.status === 502
90
+ ? 'nginx returned 502 Bad Gateway — the proxy container is usually not running or not listening yet. Try: docker compose ps proxy && docker compose logs proxy --tail 50'
91
+ : res.status === 404
92
+ ? 'route not found — check api-gateway nginx config vs Nest proxy routes'
93
+ : undefined;
94
+ return { url, ok: res.ok, status: res.status, hint };
95
+ } catch (e) {
96
+ return {
97
+ url,
98
+ ok: false,
99
+ error: e instanceof Error ? e.message : String(e),
100
+ hint: 'cannot reach host — run `ductape start` (full stack) or ensure port 4311 / 8020 is up',
101
+ };
102
+ }
103
+ }
104
+
105
+ export async function runStatus(json: boolean): Promise<void> {
106
+ const global = loadGlobalConfig();
107
+ const localUrl = global.profiles.local?.api_url ?? 'http://localhost:4311/api';
108
+ const localBase = localUrl.replace(/\/$/, '');
109
+ const checks: Record<string, unknown> = {
110
+ profiles: global.profiles,
111
+ platform_dir: resolvePlatformDir(),
112
+ workbench_ui: 'http://localhost:4310',
113
+ };
114
+
115
+ checks.local_proxy_health = await probeHealth(`${localBase}/proxy/v1/sdk-proxy/health`);
116
+ checks.local_proxy_direct = await probeHealth(
117
+ 'http://localhost:8020/proxy/v1/sdk-proxy/health',
118
+ );
119
+
120
+ checks.cloud_api = getApiUrl('cloud');
121
+
122
+ printJson(checks, json);
123
+ }
@@ -0,0 +1,104 @@
1
+ import { schemaKeyForEntity } from '../lib/forms/index.js';
2
+ import { isInteractive, promptTag } from '../lib/forms/prompt.js';
3
+ import { toInteractiveOpts, type CommandInteractiveFlags } from '../lib/interactive-opts.js';
4
+ import { bodyRequiredHint, resolveBody } from '../lib/read-body.js';
5
+ import {
6
+ createProduct,
7
+ deleteProduct,
8
+ getProduct,
9
+ listProductApps,
10
+ listProducts,
11
+ updateProduct,
12
+ } from '../lib/platform-api.js';
13
+ import { printJson } from '../lib/output.js';
14
+ import { requireWorkspaceContext } from '../lib/workspace-context.js';
15
+
16
+ const VERBS = ['list', 'get', 'create', 'update', 'delete'] as const;
17
+ type Verb = (typeof VERBS)[number];
18
+
19
+ export async function runProducts(
20
+ verb: string,
21
+ opts: {
22
+ profile?: string;
23
+ status?: string;
24
+ id?: string;
25
+ tag?: string;
26
+ file?: string;
27
+ json?: boolean;
28
+ } & CommandInteractiveFlags,
29
+ extraArgs: string[],
30
+ ): Promise<void> {
31
+ const v = verb.toLowerCase() as Verb;
32
+ if (!VERBS.includes(v)) {
33
+ throw new Error(`Unknown products verb "${verb}". Use: ${VERBS.join(', ')}`);
34
+ }
35
+
36
+ const ctx = requireWorkspaceContext({ profile: opts.profile });
37
+ const status = opts.status ?? 'all';
38
+ const interactive = toInteractiveOpts(opts);
39
+ const schemaKey = schemaKeyForEntity('products', v);
40
+ const body =
41
+ v === 'create' || v === 'update'
42
+ ? await resolveBody({
43
+ ...interactive,
44
+ file: opts.file,
45
+ schemaKey,
46
+ patch: v === 'update',
47
+ requireBody: v === 'create',
48
+ })
49
+ : undefined;
50
+ const id = opts.id ?? extraArgs[0];
51
+ let tag = opts.tag ?? (extraArgs[0] && !opts.id ? extraArgs[0] : undefined);
52
+ if (v === 'update' && !tag && isInteractive(interactive)) {
53
+ tag = await promptTag('Product tag');
54
+ }
55
+
56
+ let result: unknown;
57
+
58
+ switch (v) {
59
+ case 'list':
60
+ result = await listProducts(ctx, status);
61
+ break;
62
+ case 'get':
63
+ result = await getProduct(ctx, { id, tag });
64
+ break;
65
+ case 'create':
66
+ if (!body || Object.keys(body).length === 0) {
67
+ throw new Error(`create requires a body. ${bodyRequiredHint('products:create')}`);
68
+ }
69
+ result = await createProduct(ctx, body);
70
+ break;
71
+ case 'update': {
72
+ const productTag = tag ?? (body?.tag as string | undefined);
73
+ if (!productTag) throw new Error('update requires --tag <product_tag> (or tag in body)');
74
+ const patch = body ?? {};
75
+ result = await updateProduct(ctx, productTag, patch);
76
+ break;
77
+ }
78
+ case 'delete':
79
+ if (!id) throw new Error('delete requires --id <product_id>');
80
+ result = await deleteProduct(ctx, id);
81
+ break;
82
+ default:
83
+ throw new Error(`Unsupported: products ${v}`);
84
+ }
85
+
86
+ printJson(result, Boolean(opts.json));
87
+ }
88
+
89
+ export async function runProductApps(
90
+ verb: string,
91
+ opts: { profile?: string; product?: string; json?: boolean },
92
+ extraArgs: string[],
93
+ ): Promise<void> {
94
+ if (verb.toLowerCase() !== 'list') {
95
+ throw new Error('Only supported: ductape products apps list --product <id>');
96
+ }
97
+
98
+ const ctx = requireWorkspaceContext({ profile: opts.profile });
99
+ const productId = opts.product ?? extraArgs[0];
100
+ if (!productId) throw new Error('Provide --product <product_id>');
101
+
102
+ const result = await listProductApps(ctx, productId);
103
+ printJson(result, Boolean(opts.json));
104
+ }