@ductape/cli 0.3.22 → 0.3.24

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/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Browser OAuth now completes through a validated loopback callback and logs the CLI in automatically.
6
+ - Feature sync now establishes the official catalogue-only lifecycle with `DUCTAPE_SYNC_MODE=1` and exposes filtered ownership through `DUCTAPE_FEATURE_FILTER` before project bootstrap.
7
+
3
8
  ## 0.3.0 - 2026-07-26
4
9
 
5
10
  - Added AI-led migration planning with new-codebase defaults and immutable original E2E gates.
package/README.md CHANGED
@@ -48,6 +48,8 @@ Do not use `sudo npm link`; it mixes root-owned globals with your normal user.
48
48
  ```bash
49
49
  ductape install
50
50
  ductape login # uses `cloud` profile (https://api.ductape.app)
51
+ ductape login --browser google # browser OAuth; returns to the CLI automatically
52
+ ductape login --browser github
51
53
  # Local platform (UI + API on your machine):
52
54
  ductape profiles use local
53
55
  ductape start
@@ -1,6 +1,9 @@
1
1
  export interface FeaturesSyncOpts {
2
2
  filter?: string;
3
3
  }
4
+ export declare const DUCTAPE_CATALOGUE_SYNC_ENV = "DUCTAPE_SYNC_MODE";
5
+ export declare const DUCTAPE_FEATURE_FILTER_ENV = "DUCTAPE_FEATURE_FILTER";
6
+ export declare function buildFeaturesSyncEnvironment(filter?: string, base?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
4
7
  /**
5
8
  * Persists code-first Ductape Features to the live product. Unlike `ductape db migrate` (which
6
9
  * executes declarative migration files directly), a Feature's handler is real application code
@@ -3,6 +3,16 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { findProjectConfig } from '../lib/config.js';
5
5
  import { fail } from '../lib/output.js';
6
+ export const DUCTAPE_CATALOGUE_SYNC_ENV = 'DUCTAPE_SYNC_MODE';
7
+ export const DUCTAPE_FEATURE_FILTER_ENV = 'DUCTAPE_FEATURE_FILTER';
8
+ export function buildFeaturesSyncEnvironment(filter, base = process.env) {
9
+ const env = { ...base, [DUCTAPE_CATALOGUE_SYNC_ENV]: '1' };
10
+ if (filter)
11
+ env[DUCTAPE_FEATURE_FILTER_ENV] = filter;
12
+ else
13
+ delete env[DUCTAPE_FEATURE_FILTER_ENV];
14
+ return env;
15
+ }
6
16
  const CONVENTION_HINT = `
7
17
  Ductape convention for code-first Features:
8
18
  1. Define every Feature under ductape/features/ (e.g. ductape/features/src/my-feature.ts),
@@ -13,6 +23,11 @@ Ductape convention for code-first Features:
13
23
  construct real dependencies (no HTTP listener) and calls every registerXFeature(...) once.
14
24
  4. Run it explicitly with \`ductape features sync\` — never automatically on app boot.
15
25
 
26
+ The CLI sets DUCTAPE_SYNC_MODE=1 for the child process. Ductape framework integrations use this
27
+ official catalogue-only signal to skip serving-time consumers and provider readiness. Project
28
+ readiness hooks should do the same. When a filter is supplied, it is also available as
29
+ DUCTAPE_FEATURE_FILTER and must be applied before booting unrelated service modules.
30
+
16
31
  This mirrors \`ductape db migrate\`: Features are defined in source, but persisted to the live
17
32
  product via an explicit command, so a slow or unreachable Ductape API can never block your app
18
33
  from starting.
@@ -46,8 +61,12 @@ export async function runFeaturesSync(opts) {
46
61
  const npmArgs = ['run', 'features:sync'];
47
62
  if (opts.filter)
48
63
  npmArgs.push('--', opts.filter);
49
- console.log(`Running "npm ${npmArgs.join(' ')}" in ${dir} ...\n`);
50
- const result = spawnSync('npm', npmArgs, { cwd: dir, stdio: 'inherit' });
64
+ console.log(`Running "npm ${npmArgs.join(' ')}" in ${dir} (catalogue sync mode) ...\n`);
65
+ const result = spawnSync('npm', npmArgs, {
66
+ cwd: dir,
67
+ stdio: 'inherit',
68
+ env: buildFeaturesSyncEnvironment(opts.filter),
69
+ });
51
70
  if (result.status !== 0) {
52
71
  fail(`features:sync exited with code ${result.status ?? 1}.`);
53
72
  }
@@ -1,6 +1,8 @@
1
1
  import inputPrompt from '@inquirer/input';
2
2
  import passwordPrompt from '@inquirer/password';
3
3
  import { spawn } from 'node:child_process';
4
+ import { createServer } from 'node:http';
5
+ import { randomBytes } from 'node:crypto';
4
6
  import { getApiUrl, getActiveProfileName, saveCredentials } from '../lib/config.js';
5
7
  import { parseJsonResponse } from '../lib/http.js';
6
8
  import { finalizeLoginWorkspace } from '../lib/workspaces.js';
@@ -38,6 +40,58 @@ function openBrowser(url) {
38
40
  : 'xdg-open';
39
41
  spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
40
42
  }
43
+ async function browserOAuthLogin(apiUrl, provider) {
44
+ const state = randomBytes(24).toString('base64url');
45
+ let timeout;
46
+ const tokenPromise = new Promise((resolve, reject) => {
47
+ const server = createServer((req, res) => {
48
+ const url = new URL(req.url || '/', 'http://127.0.0.1');
49
+ if (url.pathname !== '/callback') {
50
+ res.writeHead(404).end('Not found');
51
+ return;
52
+ }
53
+ const token = url.searchParams.get('token');
54
+ const returnedState = url.searchParams.get('state');
55
+ const error = url.searchParams.get('error');
56
+ const ok = !error && Boolean(token) && returnedState === state;
57
+ res.writeHead(ok ? 200 : 400, { 'Content-Type': 'text/html; charset=utf-8' });
58
+ res.end(ok
59
+ ? '<!doctype html><title>Ductape CLI</title><p>Login complete. You can close this window.</p>'
60
+ : '<!doctype html><title>Ductape CLI</title><p>Login failed. Return to your terminal.</p>');
61
+ server.close();
62
+ if (timeout)
63
+ clearTimeout(timeout);
64
+ if (error)
65
+ reject(new Error(`OAuth login failed: ${error}`));
66
+ else if (!token || returnedState !== state)
67
+ reject(new Error('OAuth callback validation failed'));
68
+ else
69
+ resolve(token);
70
+ });
71
+ server.on('error', reject);
72
+ server.listen(0, '127.0.0.1', () => {
73
+ const address = server.address();
74
+ if (!address || typeof address === 'string') {
75
+ server.close();
76
+ reject(new Error('Could not start OAuth callback listener'));
77
+ return;
78
+ }
79
+ const callback = `http://127.0.0.1:${address.port}/callback`;
80
+ const authUrl = new URL(`${apiUrl}/users/v1/auth/${provider}`);
81
+ authUrl.searchParams.set('cli_redirect', callback);
82
+ authUrl.searchParams.set('cli_state', state);
83
+ console.log(`Opening ${provider === 'github' ? 'GitHub' : 'Google'} in your browser…`);
84
+ console.log(`If the browser does not open, visit:\n ${authUrl.toString()}\n`);
85
+ openBrowser(authUrl.toString());
86
+ });
87
+ timeout = setTimeout(() => {
88
+ server.close();
89
+ reject(new Error('OAuth login timed out after 5 minutes'));
90
+ }, 5 * 60 * 1000);
91
+ timeout.unref();
92
+ });
93
+ return exchangeOAuthToken(apiUrl, await tokenPromise);
94
+ }
41
95
  export async function runLogin(opts) {
42
96
  const profileName = opts.profile ?? getActiveProfileName();
43
97
  const apiUrl = getApiUrl(opts.profile);
@@ -53,14 +107,14 @@ export async function runLogin(opts) {
53
107
  return;
54
108
  }
55
109
  if (opts.browser) {
56
- const path = opts.browser === 'github' ? '/users/v1/auth/github' : '/users/v1/auth/google';
57
- const authUrl = `${apiUrl}${path}`;
58
- console.log('Opening browser for OAuth login…');
59
- console.log(`If the browser does not open, visit:\n ${authUrl}\n`);
60
- console.log('After sign-in you will be redirected with ?loggedIn=true&token=… in the URL.\n' +
61
- 'Copy the token value and run:\n' +
62
- ' ductape login --token "<paste-token-here>"\n');
63
- openBrowser(authUrl);
110
+ const user = await browserOAuthLogin(apiUrl, opts.browser);
111
+ saveFromResult(user);
112
+ success(`Logged in as ${user.email} (OAuth)`);
113
+ await finalizeLoginWorkspace({
114
+ profile: opts.profile,
115
+ workspace: opts.workspace,
116
+ skipPrompt: opts.skipWorkspaceSelect,
117
+ });
64
118
  return;
65
119
  }
66
120
  let email = opts.email;
@@ -291,7 +291,7 @@ export async function runResourceCrud(typeName, verb, opts, extraArgs) {
291
291
  }
292
292
  }
293
293
  const method = proxyMethodForVerb(crud);
294
- const proxyMethod = module === 'models' && method === 'list' ? 'fetchAll' : method;
294
+ const proxyMethod = (module === 'models' || module === 'feature') && method === 'list' ? 'fetchAll' : method;
295
295
  const interactive = toInteractiveOpts(opts);
296
296
  let tag = opts.tag ?? extraArgs[0];
297
297
  if (!tag && ['get', 'update', 'delete'].includes(crud) && isInteractive(interactive)) {
package/dist/index.js CHANGED
@@ -74,7 +74,7 @@ program
74
74
  .option('-e, --email <email>')
75
75
  .option('-p, --password <password>')
76
76
  .option('--token <token>', 'OAuth callback token from browser redirect URL')
77
- .option('--browser [provider]', 'Open browser for Google OAuth (google|github)')
77
+ .option('--browser [provider]', 'Log in through browser OAuth (google|github)')
78
78
  .option('--profile <name>', 'API profile', 'cloud')
79
79
  .option('-w, --workspace <id>', 'Workspace index, name, or id (skips interactive picker)')
80
80
  .option('--skip-workspace-select', 'Keep prior active workspace or default without prompting')
@@ -83,7 +83,15 @@ program
83
83
  password: opts.password,
84
84
  profile: opts.profile,
85
85
  token: opts.token,
86
- browser: opts.browser === true ? 'google' : opts.browser,
86
+ browser: opts.browser === undefined
87
+ ? undefined
88
+ : opts.browser === true
89
+ ? 'google'
90
+ : opts.browser === 'google' || opts.browser === 'github'
91
+ ? opts.browser
92
+ : (() => {
93
+ throw new Error('--browser provider must be google or github');
94
+ })(),
87
95
  workspace: opts.workspace,
88
96
  skipWorkspaceSelect: Boolean(opts.skipWorkspaceSelect),
89
97
  })));
@@ -1,4 +1,4 @@
1
- export type SDKModule = 'product' | 'app' | 'databases' | 'graph' | 'webhooks' | 'notifications' | 'messageBrokers' | 'storage' | 'vector' | 'caches' | 'sessions' | 'quotas' | 'actions' | 'features' | 'jobs' | 'logs' | 'resilience' | 'health' | 'fallback' | 'secrets' | 'models' | 'cloud';
1
+ export type SDKModule = 'product' | 'app' | 'databases' | 'graph' | 'webhooks' | 'notifications' | 'messageBrokers' | 'storage' | 'vector' | 'caches' | 'sessions' | 'quotas' | 'actions' | 'feature' | 'jobs' | 'logs' | 'resilience' | 'health' | 'fallback' | 'secrets' | 'models' | 'cloud';
2
2
  export interface SdkProxyContext {
3
3
  apiUrl: string;
4
4
  workspaceId: string;
@@ -11,8 +11,8 @@ export const RESOURCE_MODULES = {
11
11
  caches: 'caches',
12
12
  job: 'jobs',
13
13
  jobs: 'jobs',
14
- feature: 'features',
15
- features: 'features',
14
+ feature: 'feature',
15
+ features: 'feature',
16
16
  action: 'actions',
17
17
  actions: 'actions',
18
18
  notification: 'notifications',
@@ -31,7 +31,9 @@ export const RESOURCE_MODULES = {
31
31
  quotas: 'quotas',
32
32
  health: 'health',
33
33
  healthcheck: 'health',
34
+ healthchecks: 'health',
34
35
  fallback: 'fallback',
36
+ fallbacks: 'fallback',
35
37
  model: 'models',
36
38
  models: 'models',
37
39
  product: 'product',
@@ -125,6 +127,20 @@ export function buildCrudParams(module, method, productTag, args) {
125
127
  if (method === 'list')
126
128
  return [];
127
129
  }
130
+ if (module === 'feature') {
131
+ if (method === 'list' || method === 'fetchAll')
132
+ return [productTag];
133
+ if (method === 'fetch' || method === 'delete') {
134
+ if (!tag)
135
+ throw new Error('tag is required');
136
+ return [tag, productTag];
137
+ }
138
+ if (method === 'update') {
139
+ if (!tag)
140
+ throw new Error('tag is required');
141
+ return [tag, productTag, body ?? {}];
142
+ }
143
+ }
128
144
  // Default product-scoped components
129
145
  if (method === 'create')
130
146
  return [productTag, body ?? {}];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ductape/cli",
3
- "version": "0.3.22",
3
+ "version": "0.3.24",
4
4
  "description": "Ductape CLI — local platform, login, link projects, and manage resources via the proxy (Workbench-compatible)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",