@ductape/cli 0.3.16 → 0.3.17

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,12 @@
1
+ export interface FeaturesSyncOpts {
2
+ filter?: string;
3
+ }
4
+ /**
5
+ * Persists code-first Ductape Features to the live product. Unlike `ductape db migrate` (which
6
+ * executes declarative migration files directly), a Feature's handler is real application code
7
+ * that typically depends on the app's own services (repositories, DB connections, DI). Ductape
8
+ * cannot safely execute that code in isolation — so this command delegates to a "features:sync"
9
+ * npm script that the project itself owns and controls, giving a single consistent entrypoint
10
+ * across every Ductape project regardless of framework.
11
+ */
12
+ export declare function runFeaturesSync(opts: FeaturesSyncOpts): Promise<void>;
@@ -0,0 +1,54 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { findProjectConfig } from '../lib/config.js';
5
+ import { fail } from '../lib/output.js';
6
+ const CONVENTION_HINT = `
7
+ Ductape convention for code-first Features:
8
+ 1. Define every Feature under ductape/features/ (e.g. ductape/features/src/my-feature.ts),
9
+ each calling ductape.feature.define({ ... }) from a registerXFeature(ductape) export.
10
+ 2. In your app's normal startup path, register only LOCAL function/operation handlers
11
+ (ductape.sdk.functions.register(...)) — no network call, safe on every boot.
12
+ 3. Add a "features:sync" script to package.json that boots just enough of your app to
13
+ construct real dependencies (no HTTP listener) and calls every registerXFeature(...) once.
14
+ 4. Run it explicitly with \`ductape features sync\` — never automatically on app boot.
15
+
16
+ This mirrors \`ductape db migrate\`: Features are defined in source, but persisted to the live
17
+ product via an explicit command, so a slow or unreachable Ductape API can never block your app
18
+ from starting.
19
+ `;
20
+ /**
21
+ * Persists code-first Ductape Features to the live product. Unlike `ductape db migrate` (which
22
+ * executes declarative migration files directly), a Feature's handler is real application code
23
+ * that typically depends on the app's own services (repositories, DB connections, DI). Ductape
24
+ * cannot safely execute that code in isolation — so this command delegates to a "features:sync"
25
+ * npm script that the project itself owns and controls, giving a single consistent entrypoint
26
+ * across every Ductape project regardless of framework.
27
+ */
28
+ export async function runFeaturesSync(opts) {
29
+ const found = findProjectConfig();
30
+ if (!found)
31
+ fail('No linked project. Run `ductape link` from your project directory (or `ductape init`).');
32
+ const { dir } = found;
33
+ const packageJsonPath = path.join(dir, 'package.json');
34
+ if (!fs.existsSync(packageJsonPath)) {
35
+ fail(`No package.json found at ${dir}.\n${CONVENTION_HINT}`);
36
+ }
37
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
38
+ if (!pkg.scripts?.['features:sync']) {
39
+ fail(`package.json at ${dir} has no "features:sync" script.\n${CONVENTION_HINT}`);
40
+ }
41
+ const featuresDir = path.join(dir, 'ductape', 'features');
42
+ if (!fs.existsSync(featuresDir)) {
43
+ console.warn(`Warning: ductape/features/ does not exist at ${dir}. Proceeding anyway — your ` +
44
+ '"features:sync" script may look elsewhere, but the convention is ductape/features/.');
45
+ }
46
+ const npmArgs = ['run', 'features:sync'];
47
+ if (opts.filter)
48
+ npmArgs.push('--', opts.filter);
49
+ console.log(`Running "npm ${npmArgs.join(' ')}" in ${dir} ...\n`);
50
+ const result = spawnSync('npm', npmArgs, { cwd: dir, stdio: 'inherit' });
51
+ if (result.status !== 0) {
52
+ fail(`features:sync exited with code ${result.status ?? 1}.`);
53
+ }
54
+ }
@@ -25,6 +25,7 @@ export declare function runNotificationMessageCrud(verb: string, opts: {
25
25
  notification?: string;
26
26
  file?: string;
27
27
  json?: boolean;
28
+ product?: string;
28
29
  }): Promise<void>;
29
30
  export declare function runResourceCrud(typeName: string, verb: string, opts: {
30
31
  tag?: string;
@@ -230,7 +230,9 @@ export async function runNotificationMessageCrud(verb, opts) {
230
230
  }
231
231
  const session = requireSession();
232
232
  const proxy = getSdkProxy(session);
233
- const product = session.project.product_tag;
233
+ // Explicit --product avoids silently resolving to whatever product happens to be linked in
234
+ // the current working directory — see resources.ts's runResourceCrud for the same pattern.
235
+ const product = opts.product ?? session.project.product_tag;
234
236
  const body = ['create', 'update'].includes(crud)
235
237
  ? await resolveBody({ file: opts.file, patch: crud === 'update', requireBody: crud === 'create', interactive: false })
236
238
  : undefined;
@@ -346,7 +348,7 @@ export async function runResourceCrud(typeName, verb, opts, extraArgs) {
346
348
  try {
347
349
  const reconciliation = await reconcileCreatedComponent(proxy, module, productTag, createdTag);
348
350
  if (!reconciliation.resource) {
349
- throw new DuctapeOperationError(`${error.message} Reconciliation polled the administrative product catalogue ${reconciliation.attempts} time(s) for ${reconciliation.elapsed_ms}ms and did not find "${createdTag}".`, { ...error.details, code: 'MUTATION_OUTCOME_UNKNOWN', mutationState: 'unknown' });
351
+ throw new DuctapeOperationError(`${error.message} Reconciliation polled the administrative product catalogue for product "${productTag}" ${reconciliation.attempts} time(s) for ${reconciliation.elapsed_ms}ms and did not find "${createdTag}". If this product tag looks wrong, pass --product explicitly instead of relying on the linked project.`, { ...error.details, code: 'MUTATION_OUTCOME_UNKNOWN', mutationState: 'unknown' });
350
352
  }
351
353
  printJson({
352
354
  created: true,
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { runResourceCrud, runResourcesList, runEventTopicCrud, runNotificationMe
17
17
  import { runCloud, runCloudPreflight } from './commands/cloud.js';
18
18
  import { runDb, runDbContext } from './commands/db.js';
19
19
  import { runDbMigrate, runDbMigrateStatus, runDbMigrateRollback } from './commands/db-migrate.js';
20
+ import { runFeaturesSync } from './commands/features-sync.js';
20
21
  import { runDbSchemaGenerate, runDbSchemaPush } from './commands/db-schema.js';
21
22
  import { runGraph } from './commands/graph.js';
22
23
  import { runSecretImportEnv, runSecrets } from './commands/secrets.js';
@@ -696,6 +697,7 @@ notificationMessages
696
697
  .option('-t, --tag <tag>', 'Full notification:message tag')
697
698
  .option('-n, --notification <tag>', 'Notification tag for list')
698
699
  .option('-f, --file <path>', 'JSON template body for create/update')
700
+ .option('--product <tag>', 'Product tag (defaults to linked project)')
699
701
  .option('--json', 'JSON output')
700
702
  .action(wrap((verb, opts) => runNotificationMessageCrud(verb, opts)));
701
703
  resources.command('types').option('--json', 'JSON output').action(wrap((opts) => runResourcesList(Boolean(opts.json))));
@@ -781,6 +783,14 @@ db
781
783
  throw new Error('Specify a verb: connect | query');
782
784
  return runDb(verb, { file: opts.file, json: opts.json });
783
785
  }));
786
+ const features = program.command('features').description('Code-first Ductape Features (ductape/features/)');
787
+ features
788
+ .command('sync')
789
+ .description('Persist Features from ductape/features/ to the live product by running the project\'s own ' +
790
+ '"features:sync" npm script (see `ductape features sync --help` for the required convention). ' +
791
+ 'Run explicitly, like `ductape db migrate` — never automatically on app boot.')
792
+ .argument('[filter]', 'Optional substring passed through to the project\'s features:sync script')
793
+ .action(wrap((filter) => runFeaturesSync({ filter })));
784
794
  const graph = program.command('graph').description('Graph runtime (graph-proxy)');
785
795
  graph
786
796
  .argument('<verb>', 'connect | query | createAction | validateAction | updateAction | …')
@@ -241,8 +241,16 @@ export async function connectMarketplaceApp(ctx, product, appTag, envs) {
241
241
  if (!marketplaceApp || typeof marketplaceApp !== 'object')
242
242
  throw new Error(`Marketplace app "${appTag}" was not found`);
243
243
  const app = marketplaceApp;
244
- if (app.status !== 'public')
245
- throw new Error(`App "${appTag}" is not public in the marketplace`);
244
+ // DT-021: this was a blanket "must be public" guard that also rejected an app the caller's own
245
+ // workspace owns and never even reaches the backend to find out whether it would allow it.
246
+ // ductape_marketplace_inspect has no such restriction for the same private app, so the read path
247
+ // already treats same-workspace ownership as sufficient — mirror that here for connect.
248
+ const isOwnedByCallerWorkspace = String(app.workspace_id ?? '') === String(ctx.workspaceId ?? '')
249
+ && Boolean(ctx.workspaceId);
250
+ if (app.status !== 'public' && !isOwnedByCallerWorkspace) {
251
+ throw new Error(`App "${appTag}" is not public in the marketplace and is not owned by your workspace. ` +
252
+ 'Only public marketplace apps or apps your own workspace created can be connected.');
253
+ }
246
254
  const versions = Array.isArray(app.versions) ? app.versions : [];
247
255
  const latest = versions.find((version) => version.latest === true) ?? versions[0];
248
256
  const availableAppEnvs = new Set((Array.isArray(latest?.envs) ? latest.envs : []).map((env) => String(env && typeof env === 'object' ? env.slug ?? '' : env)).filter(Boolean));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ductape/cli",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
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",