@dashai/cli 0.14.2 → 0.16.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 (3) hide show
  1. package/dist/bin.js +2476 -1311
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +2 -2
package/dist/bin.js CHANGED
@@ -3,9 +3,9 @@ import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, watch, read
3
3
  import { tmpdir, platform, homedir } from 'os';
4
4
  import { resolve, join, relative, dirname, basename, sep } from 'path';
5
5
  import pc from 'picocolors';
6
+ import { spawn, spawnSync } from 'child_process';
6
7
  import { intro, log, select, isCancel, cancel, spinner, outro, text, multiselect, confirm } from '@clack/prompts';
7
8
  import { Command, Option } from 'commander';
8
- import { spawn, spawnSync } from 'child_process';
9
9
  import open from 'open';
10
10
  import AdmZip from 'adm-zip';
11
11
  import * as tar from 'tar';
@@ -447,6 +447,15 @@ async function listWorkspaceInstallations(workspaceId, options = {}) {
447
447
  options
448
448
  );
449
449
  }
450
+ async function uninstallModuleInstallation(installationId, opts = {}, options = {}) {
451
+ const qs = opts.parkConsumers ? "?park_consumers=true" : "";
452
+ await apiRequest(
453
+ "DELETE",
454
+ `/api/modules/installations/${installationId}${qs}`,
455
+ void 0,
456
+ options
457
+ );
458
+ }
450
459
  async function applyDraftManifest(moduleId, manifest, options = {}) {
451
460
  return apiRequest(
452
461
  "PUT",
@@ -512,6 +521,79 @@ var init_output = __esm({
512
521
  "src/lib/output.ts"() {
513
522
  }
514
523
  });
524
+
525
+ // src/lib/connect-status.ts
526
+ async function reportConnectStatus(opts) {
527
+ const [deps, bindings] = await Promise.all([
528
+ safeGet(
529
+ () => getInstallationDependencies(opts.installationId, opts.requestOptions)
530
+ ),
531
+ safeGet(
532
+ () => getInstallationBindings(opts.installationId, opts.requestOptions)
533
+ )
534
+ ]);
535
+ if (deps === null && bindings === null) return;
536
+ const depList = deps ?? [];
537
+ const bindList = bindings ?? [];
538
+ if (depList.length === 0 && bindList.length === 0) return;
539
+ const unboundDeps = depList.filter((d) => d.status === "unbound").length;
540
+ const unboundUses = bindList.filter((b) => b.status === "unbound").length;
541
+ info(
542
+ `Connect status for ${code(opts.moduleSlug)} ${dim(
543
+ `(${depList.length} dep${depList.length === 1 ? "" : "s"}, ${bindList.length} uses slot${bindList.length === 1 ? "" : "s"})`
544
+ )}:`
545
+ );
546
+ for (const d of depList) renderDep(d);
547
+ for (const b of bindList) renderBinding(b);
548
+ const hints = [];
549
+ if (unboundDeps > 0) {
550
+ hints.push(
551
+ `${code("dashwise deps bind <provider-slug>")} once the provider is installed`
552
+ );
553
+ }
554
+ if (unboundUses > 0) {
555
+ hints.push(`${code("dashwise bind <uses-slug> --table <name>")} to connect a table`);
556
+ }
557
+ if (hints.length > 0) {
558
+ info(`Connect parked items with ${hints.join("; ")}.`);
559
+ }
560
+ out("");
561
+ }
562
+ function renderDep(d) {
563
+ if (d.status === "resolved") {
564
+ const providerLine = d.provider ? ` ${dim(`\u2192 install #${d.provider.installation_id} @ v${d.provider.version}`)}` : "";
565
+ out(` ${pc.green("\u25CF resolved")} ${dim("dep")} ${code(d.slug)}@${d.range ?? "*"}${providerLine}`);
566
+ } else {
567
+ out(
568
+ ` ${pc.yellow("\u25CB unbound")} ${dim("dep")} ${code(d.slug)}@${d.range ?? "*"} ${dim(`reason: ${d.reason ?? "(unspecified)"}`)}`
569
+ );
570
+ }
571
+ }
572
+ function renderBinding(b) {
573
+ if (b.status === "bound") {
574
+ const tableLine = b.table ? ` ${dim(`\u2192 ${b.table.name} (id ${b.table.id})`)}` : "";
575
+ out(` ${pc.green("\u25CF bound")} ${dim("uses")} ${code(b.uses_slug)}${tableLine}`);
576
+ } else if (b.status === "declined") {
577
+ out(` ${pc.dim("\u2298 declined")} ${dim("uses")} ${code(b.uses_slug)}`);
578
+ } else {
579
+ out(
580
+ ` ${pc.yellow("\u25CB unbound")} ${dim("uses")} ${code(b.uses_slug)} ${dim(`reason: ${b.reason ?? "(unspecified)"}`)}`
581
+ );
582
+ }
583
+ }
584
+ async function safeGet(fn) {
585
+ try {
586
+ return await fn();
587
+ } catch (err) {
588
+ return null;
589
+ }
590
+ }
591
+ var init_connect_status = __esm({
592
+ "src/lib/connect-status.ts"() {
593
+ init_api_client();
594
+ init_output();
595
+ }
596
+ });
515
597
  function manifestPath(dir) {
516
598
  return join(resolve(dir ?? process.cwd()), "module.json");
517
599
  }
@@ -545,137 +627,751 @@ var init_manifest_io = __esm({
545
627
  }
546
628
  });
547
629
 
548
- // src/lib/scaffold.ts
549
- function isDeployTarget(value) {
550
- return DEPLOY_TARGETS.includes(value);
630
+ // src/lib/manifest-merge.ts
631
+ function tablesBySlug(manifest) {
632
+ const map = /* @__PURE__ */ new Map();
633
+ const owns = manifest?.owns;
634
+ if (!owns || typeof owns !== "object") return map;
635
+ const tables = owns.tables;
636
+ if (!Array.isArray(tables)) return map;
637
+ for (const t of tables) {
638
+ if (t && typeof t === "object" && typeof t.slug === "string") {
639
+ map.set(t.slug, t);
640
+ }
641
+ }
642
+ return map;
643
+ }
644
+ function mergeRemoteManifest(local, remote) {
645
+ const preserved = [];
646
+ const merged = { ...remote };
647
+ if (!local || typeof local !== "object" || Array.isArray(local)) {
648
+ return { merged, preserved };
649
+ }
650
+ for (const [key, value] of Object.entries(local)) {
651
+ if (!(key in remote)) {
652
+ merged[key] = value;
653
+ preserved.push(key);
654
+ }
655
+ }
656
+ const localTables = tablesBySlug(local);
657
+ const remoteOwns = remote.owns;
658
+ if (remoteOwns && typeof remoteOwns === "object" && Array.isArray(remoteOwns.tables)) {
659
+ const remoteTables = remoteOwns.tables;
660
+ let touched = false;
661
+ const mergedTables = remoteTables.map((t) => {
662
+ if (!t || typeof t !== "object") return t;
663
+ const slug = t.slug;
664
+ if (typeof slug !== "string") return t;
665
+ const localTable = localTables.get(slug);
666
+ if (localTable && Array.isArray(localTable.indexes) && t.indexes === void 0) {
667
+ preserved.push(`owns.tables[${slug}].indexes`);
668
+ touched = true;
669
+ return { ...t, indexes: localTable.indexes };
670
+ }
671
+ return t;
672
+ });
673
+ if (touched) {
674
+ merged.owns = { ...remoteOwns, tables: mergedTables };
675
+ }
676
+ }
677
+ return { merged, preserved };
551
678
  }
552
- function manifestJson(opts) {
553
- const kind = opts.kind ?? "hand_authored";
554
- const manifest = {
555
- schema_version: 2,
556
- module: {
557
- slug: opts.slug,
558
- name: opts.name,
559
- version: "0.0.1"
560
- },
561
- module_kind: kind,
562
- owns: {
563
- tables: [
564
- {
565
- // NOTE: no `name` key here — the backend manifest validator
566
- // (POST /api/installations/dev) is strict and rejects unrecognized
567
- // table keys, and it derives the display name from the slug. A
568
- // `name` here would fail provisioning's table materialization (#4).
569
- slug: "items",
570
- fields: [
571
- { slug: "title", type: "text" },
572
- { slug: "done", type: "boolean" }
573
- ]
574
- }
575
- ]
679
+ function fieldSlugs(table) {
680
+ const set = /* @__PURE__ */ new Set();
681
+ const fields = table?.fields;
682
+ if (!Array.isArray(fields)) return set;
683
+ for (const f of fields) {
684
+ if (f && typeof f === "object" && typeof f.slug === "string") {
685
+ set.add(f.slug);
576
686
  }
577
- };
578
- if (kind === "custom") {
579
- manifest.runtime = {
580
- type: "nextjs",
581
- kind: "machine",
582
- machine_size: "shared-cpu-1x",
583
- server_actions: true
584
- };
585
687
  }
586
- if (opts.repositoryUrl) {
587
- manifest.repository = { type: "git", url: opts.repositoryUrl };
688
+ return set;
689
+ }
690
+ function computeDestructiveDelta(local, remote) {
691
+ const tablesRemoved = [];
692
+ const fieldsRemoved = [];
693
+ const localTables = tablesBySlug(local);
694
+ const remoteTables = tablesBySlug(remote);
695
+ for (const [slug, remoteTable] of remoteTables) {
696
+ const localTable = localTables.get(slug);
697
+ if (!localTable) {
698
+ tablesRemoved.push(slug);
699
+ continue;
700
+ }
701
+ const localFields = fieldSlugs(localTable);
702
+ for (const field of fieldSlugs(remoteTable)) {
703
+ if (!localFields.has(field)) fieldsRemoved.push(`${slug}.${field}`);
704
+ }
588
705
  }
589
- return JSON.stringify(manifest, null, 2) + "\n";
706
+ return { tablesRemoved, fieldsRemoved };
590
707
  }
591
- function packageJson(opts) {
592
- return JSON.stringify(
593
- {
594
- name: opts.slug,
595
- version: "0.0.1",
596
- private: true,
597
- type: "module",
598
- scripts: {
599
- dev: "dashwise module dev",
600
- "dev:next": "next dev",
601
- "dashwise:generate": "dashwise module generate",
602
- build: "next build",
603
- start: "next start"
604
- },
605
- dependencies: {
606
- "@dashai/sdk": "^2.0.0",
607
- next: "^15.0.0",
608
- react: "^19.0.0",
609
- "react-dom": "^19.0.0"
610
- },
611
- devDependencies: {
612
- "@types/node": "^22.0.0",
613
- "@types/react": "^19.0.0",
614
- "@types/react-dom": "^19.0.0",
615
- typescript: "^5.5.0"
708
+ var PRESERVED_BLOCKS_NOTICE;
709
+ var init_manifest_merge = __esm({
710
+ "src/lib/manifest-merge.ts"() {
711
+ PRESERVED_BLOCKS_NOTICE = "[sync] preserved local-only manifest blocks (rbac/indexes) \u2014 run `dashwise schema push` to apply them to the dev install";
712
+ }
713
+ });
714
+ function startManifestPollLoop(opts) {
715
+ const {
716
+ manifestPath: manifestPath2,
717
+ requestOptions,
718
+ intervalMs,
719
+ onUpdate,
720
+ onError
721
+ } = opts;
722
+ let lastSeenRemoteJson = null;
723
+ let timer = null;
724
+ let stopped = false;
725
+ let preservedNoticeShown = false;
726
+ const tick = async () => {
727
+ if (stopped) return;
728
+ try {
729
+ const { manifest } = await getInstallationSchema(requestOptions);
730
+ const remoteJson = JSON.stringify(manifest, null, 2) + "\n";
731
+ const currentLocal = existsSync(manifestPath2) ? readFileSync(manifestPath2, "utf-8") : "";
732
+ if (remoteJson === currentLocal) {
733
+ lastSeenRemoteJson = remoteJson;
734
+ } else if (remoteJson === lastSeenRemoteJson) {
735
+ } else {
736
+ const localParsed = (() => {
737
+ try {
738
+ return currentLocal ? JSON.parse(currentLocal) : null;
739
+ } catch {
740
+ return null;
741
+ }
742
+ })();
743
+ const { merged, preserved } = mergeRemoteManifest(localParsed, manifest);
744
+ const mergedJson = JSON.stringify(merged, null, 2) + "\n";
745
+ lastSeenRemoteJson = remoteJson;
746
+ if (mergedJson !== currentLocal) {
747
+ writeFileSync(manifestPath2, mergedJson, "utf-8");
748
+ if (onUpdate) {
749
+ const delta = Buffer.byteLength(mergedJson, "utf8") - Buffer.byteLength(currentLocal, "utf8");
750
+ const deltaStr = delta === 0 ? "0 bytes" : `${delta > 0 ? "+" : ""}${delta} bytes`;
751
+ onUpdate(
752
+ `Backend manifest changed \u2014 synced to module.json (${deltaStr}).`
753
+ );
754
+ if (preserved.length > 0 && !preservedNoticeShown) {
755
+ preservedNoticeShown = true;
756
+ onUpdate(PRESERVED_BLOCKS_NOTICE);
757
+ }
758
+ }
759
+ }
760
+ }
761
+ } catch (err) {
762
+ if (onError) {
763
+ const message = err instanceof ApiError ? `${err.code} (HTTP ${err.status}): ${err.message}` : err.message;
764
+ onError(new Error(message));
765
+ }
766
+ } finally {
767
+ if (!stopped) {
768
+ timer = setTimeout(() => void tick(), intervalMs);
769
+ }
770
+ }
771
+ };
772
+ timer = setTimeout(() => void tick(), intervalMs);
773
+ return {
774
+ stop: () => {
775
+ if (stopped) return;
776
+ stopped = true;
777
+ if (timer) {
778
+ clearTimeout(timer);
779
+ timer = null;
616
780
  }
617
781
  },
618
- null,
619
- 2
620
- ) + "\n";
621
- }
622
- function tsconfigJson() {
623
- return JSON.stringify(
624
- {
625
- compilerOptions: {
626
- target: "ES2022",
627
- lib: ["dom", "dom.iterable", "ES2022"],
628
- module: "ESNext",
629
- moduleResolution: "Bundler",
630
- jsx: "preserve",
631
- strict: true,
632
- skipLibCheck: true,
633
- resolveJsonModule: true,
634
- esModuleInterop: true,
635
- isolatedModules: true,
636
- allowJs: false,
637
- noEmit: true,
638
- incremental: true,
639
- plugins: [{ name: "next" }],
640
- paths: { "@/*": ["./*"] }
641
- },
642
- include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
643
- exclude: ["node_modules"]
644
- },
645
- null,
646
- 2
647
- ) + "\n";
648
- }
649
- function nextConfig() {
650
- return `/** @type {import("next").NextConfig} */
651
- //
652
- // Phase 2 two-var contract: the deploy env supplies exactly
653
- // DASHWISE_API_URL + DASHWISE_API_KEY. The browser needs the API URL
654
- // too (the SDK's <AuthProvider> calls /api/auth/sessions/me on it), so
655
- // we DERIVE NEXT_PUBLIC_DASHWISE_API_URL here from DASHWISE_API_URL at
656
- // build/start time \u2014 you never set the NEXT_PUBLIC_ var yourself. If
657
- // you already have one set (e.g. a custom setup), it wins.
658
- const apiUrl = process.env.DASHWISE_API_URL;
659
-
660
- const nextConfig = {
661
- // Required for the platform-hosted (per-install Lambda) runtime: the
662
- // injected wrapper probes [.next/standalone/server.js, server.js] and
663
- // exit(1)s the cold start if neither exists. Without this the built
664
- // Lambda FATALs. Harmless for local \`next dev\` / \`next start\`.
665
- output: 'standalone',
666
- typescript: { ignoreBuildErrors: true },
667
- eslint: { ignoreDuringBuilds: true },
668
- env: {
669
- NEXT_PUBLIC_DASHWISE_API_URL:
670
- process.env.NEXT_PUBLIC_DASHWISE_API_URL ?? apiUrl,
671
- },
672
- };
673
- export default nextConfig;
674
- `;
782
+ isRunning: () => !stopped
783
+ };
675
784
  }
676
- function nextEnvDts() {
677
- return `/// <reference types="next" />
678
- /// <reference types="next/image-types/global" />
785
+ var init_manifest_poll = __esm({
786
+ "src/lib/manifest-poll.ts"() {
787
+ init_api_client();
788
+ init_manifest_merge();
789
+ }
790
+ });
791
+ async function devCommand(options) {
792
+ if (options.sdkOnly && options.nextOnly) {
793
+ fail(
794
+ `Cannot pass both ${code("--sdk-only")} and ${code("--next-only")} \u2014 they're mutually exclusive.`
795
+ );
796
+ return 1;
797
+ }
798
+ const projectRoot = resolve(options.dir ?? process.cwd());
799
+ const manifestPath2 = `${projectRoot}/module.json`;
800
+ if (!existsSync(manifestPath2)) {
801
+ fail(`No ${code("module.json")} found in ${code(projectRoot)}.`);
802
+ info(`Run ${code("dashwise module init <slug>")} to scaffold a new module first.`);
803
+ return 1;
804
+ }
805
+ const manifestSlug = (() => {
806
+ try {
807
+ return readManifest(projectRoot)?.module?.slug;
808
+ } catch {
809
+ return void 0;
810
+ }
811
+ })();
812
+ const savedConfig = (() => {
813
+ try {
814
+ return readConfig();
815
+ } catch {
816
+ return null;
817
+ }
818
+ })();
819
+ const apiToken = process.env.DASHWISE_API_TOKEN ?? savedConfig?.token;
820
+ if (!apiToken) {
821
+ fail(`Not authenticated. Run ${code("dashwise login")} first.`);
822
+ info(
823
+ "The unified dev loop reads your CLI token from ~/.config/dashwise/auth.json and injects it into both child processes so the SDK can authenticate without you editing .env.local."
824
+ );
825
+ return 1;
826
+ }
827
+ const apiUrl = options.apiUrl ?? process.env.DASHWISE_API_URL ?? savedConfig?.apiUrl ?? "http://localhost:3000";
828
+ let port;
829
+ try {
830
+ port = await resolveDevPort(options.port);
831
+ } catch (err) {
832
+ fail(err.message);
833
+ return 1;
834
+ }
835
+ const explicitInstallationId = options.installationId ?? process.env.DASHWISE_INSTALLATION_ID;
836
+ const reuseDevSessionId = process.env.DASHWISE_DEV_SESSION_ID;
837
+ let moduleEntry = manifestSlug !== void 0 ? readModuleEntry(manifestSlug) : null;
838
+ const healed = manifestSlug !== void 0 ? await resolveModuleApiKey(manifestSlug, moduleEntry, apiUrl, apiToken) : null;
839
+ if (healed) moduleEntry = healed;
840
+ const apiKeyEnv = process.env.DASHWISE_API_KEY ?? moduleEntry?.apiKey;
841
+ let installationIdEnv;
842
+ let revokeDevSession;
843
+ let devHandoffEnv = {};
844
+ if (explicitInstallationId && explicitInstallationId !== "local") {
845
+ installationIdEnv = explicitInstallationId;
846
+ } else if (reuseDevSessionId) ; else {
847
+ const effectiveWorkspace = options.workspace ?? moduleEntry?.workspaceId?.toString();
848
+ let workspaceContext;
849
+ try {
850
+ workspaceContext = await resolveTargetWorkspace(
851
+ apiUrl,
852
+ apiToken,
853
+ effectiveWorkspace
854
+ );
855
+ } catch (err) {
856
+ fail(err.message);
857
+ return 1;
858
+ }
859
+ const callbackOrigins = computeCallbackOrigins(port, options.callbackOrigin ?? []);
860
+ try {
861
+ const session = await registerDevSession(apiUrl, apiToken, {
862
+ target_workspace_id: workspaceContext.id,
863
+ callback_origins: callbackOrigins
864
+ });
865
+ revokeDevSession = () => revokeDevSessionBestEffort(apiUrl, apiToken, session.id);
866
+ devHandoffEnv = devSessionHandoffEnv(session);
867
+ info(` ${dim("dev session")} ${session.id}`);
868
+ info(` ${dim("workspace")} ${workspaceContext.name} (id=${workspaceContext.id}, slug=${workspaceContext.slug})`);
869
+ info(` ${dim("callbacks")} ${callbackOrigins.join(", ")}`);
870
+ info(` ${dim("expires")} ${session.expires_at}`);
871
+ info(
872
+ ` ${dim("sign-in")} ${Object.keys(devHandoffEnv).length > 0 ? "zero-click (server-side dev handoff armed)" : "browser SSO (backend issued no handoff secret \u2014 pre-2026-07-15 backend)"}`
873
+ );
874
+ } catch (err) {
875
+ if (err instanceof ApiError) {
876
+ fail(`Failed to register dev session: ${err.code} \u2014 ${err.message}`);
877
+ } else {
878
+ fail(`Failed to register dev session: ${err.message}`);
879
+ }
880
+ return 1;
881
+ }
882
+ }
883
+ const childEnv = {
884
+ ...process.env,
885
+ DASHWISE_API_URL: apiUrl,
886
+ NEXT_PUBLIC_DASHWISE_API_URL: apiUrl,
887
+ ...apiKeyEnv !== void 0 ? { DASHWISE_API_KEY: apiKeyEnv } : {},
888
+ ...devHandoffEnv
889
+ };
890
+ info(`Dev loop starting \u2014 Ctrl-C to stop.`);
891
+ info(` ${dim("api")} ${apiUrl}`);
892
+ if (installationIdEnv !== void 0) {
893
+ info(` ${dim("installation")} ${installationIdEnv} (prod-mode SSO)`);
894
+ } else {
895
+ info(` ${dim("mode")} dev (dev SSO session registered)`);
896
+ }
897
+ info(` ${dim("cli token")} ${maskedToken(apiToken)} ${dim(`(from ${apiToken === savedConfig?.token ? "~/.config/dashwise/auth.json" : "$DASHWISE_API_TOKEN"})`)}`);
898
+ if (apiKeyEnv !== void 0) {
899
+ const source = process.env.DASHWISE_API_KEY === apiKeyEnv ? "$DASHWISE_API_KEY" : `~/.config/dashwise/auth.json#modules[${manifestSlug}]`;
900
+ const installId = moduleEntry?.installationId;
901
+ info(
902
+ ` ${dim("api key")} ${maskedToken(apiKeyEnv)} ${dim(`(from ${source}${installId ? `, install id=${installId}` : ""})`)}`
903
+ );
904
+ } else if (manifestSlug !== void 0 && installationIdEnv === void 0) {
905
+ fail(
906
+ `No ${code("dwk_")} API key for module ${code(manifestSlug)} \u2014 the module will run with NO token.`
907
+ );
908
+ info(
909
+ ` Data-plane calls (db, qb) AND sign-in will fail until you provision a dev install:
910
+ ${code(`dashwise init ${manifestSlug} --workspace <your-workspace-slug>`)}
911
+ (Ensure you're logged into the right backend: ${code(`dashwise login --api-url ${apiUrl}`)}.)`
912
+ );
913
+ }
914
+ info("");
915
+ if (moduleEntry !== null && manifestSlug !== void 0) {
916
+ await reportConnectStatus({
917
+ installationId: moduleEntry.installationId,
918
+ moduleSlug: manifestSlug,
919
+ requestOptions: { baseUrl: apiUrl, auth: { token: apiToken } }
920
+ });
921
+ }
922
+ const children = [];
923
+ if (!options.nextOnly) {
924
+ const sdkChild = spawnSdkWatcher(projectRoot, childEnv);
925
+ children.push({ name: "sdk", child: sdkChild, prefix: PREFIX_SDK });
926
+ }
927
+ if (!options.sdkOnly) {
928
+ const nextChild = spawnNextDev(projectRoot, childEnv, port);
929
+ children.push({ name: "next", child: nextChild, prefix: PREFIX_NEXT });
930
+ }
931
+ for (const { child, prefix } of children) {
932
+ pipeWithPrefix(child.stdout, process.stdout, prefix);
933
+ pipeWithPrefix(child.stderr, process.stderr, prefix);
934
+ }
935
+ let manifestPoll = null;
936
+ const pollEntry = moduleEntry;
937
+ const enablePoll = !options.noRemotePoll && pollEntry !== null && apiKeyEnv !== void 0 && manifestSlug !== void 0;
938
+ if (enablePoll && pollEntry !== null) {
939
+ const intervalSec = Math.max(
940
+ MIN_REMOTE_POLL_INTERVAL_SEC,
941
+ options.remotePollIntervalSec ?? DEFAULT_REMOTE_POLL_INTERVAL_SEC
942
+ );
943
+ manifestPoll = startManifestPollLoop({
944
+ manifestPath: manifestPath2,
945
+ requestOptions: {
946
+ baseUrl: apiUrl,
947
+ // The `dwk_` key resolves the install server-side.
948
+ auth: { token: apiKeyEnv }
949
+ },
950
+ intervalMs: intervalSec * 1e3,
951
+ onUpdate: (msg) => {
952
+ process.stdout.write(`${PREFIX_POLL}${msg}
953
+ `);
954
+ },
955
+ onError: (err) => {
956
+ process.stderr.write(`${PREFIX_POLL}${err.message}
957
+ `);
958
+ }
959
+ });
960
+ info(
961
+ ` ${dim("poll")} backend manifest every ${intervalSec}s ${dim("(--no-remote-poll to disable)")}`
962
+ );
963
+ info("");
964
+ }
965
+ let shuttingDown = false;
966
+ const forwardSignal = (sig) => {
967
+ if (shuttingDown) return;
968
+ shuttingDown = true;
969
+ if (manifestPoll) {
970
+ manifestPoll.stop();
971
+ manifestPoll = null;
972
+ }
973
+ info(`
974
+ Received ${sig}, stopping children\u2026`);
975
+ for (const { child } of children) {
976
+ if (child.exitCode === null && child.signalCode === null) {
977
+ child.kill(sig);
978
+ }
979
+ }
980
+ if (revokeDevSession) {
981
+ revokeDevSession().catch(() => {
982
+ });
983
+ }
984
+ };
985
+ process.on("SIGINT", () => forwardSignal("SIGINT"));
986
+ process.on("SIGTERM", () => forwardSignal("SIGTERM"));
987
+ const results = await Promise.all(
988
+ children.map(
989
+ ({ name, child }) => new Promise(
990
+ (resolveExit) => {
991
+ child.on("exit", (exitCode2, signal) => {
992
+ const killed = signal !== null;
993
+ const finalCode = exitCode2 ?? (killed ? 0 : 1);
994
+ resolveExit({ name, code: finalCode, killed });
995
+ });
996
+ }
997
+ )
998
+ )
999
+ );
1000
+ const crashed = results.find((r) => !r.killed && r.code !== 0);
1001
+ if (crashed && !shuttingDown) {
1002
+ warn(`[${crashed.name}] exited with code ${crashed.code} \u2014 stopping others.`);
1003
+ for (const { child } of children) {
1004
+ if (child.exitCode === null && child.signalCode === null) {
1005
+ child.kill("SIGTERM");
1006
+ }
1007
+ }
1008
+ }
1009
+ if (manifestPoll) {
1010
+ manifestPoll.stop();
1011
+ manifestPoll = null;
1012
+ }
1013
+ const exitCode = Math.max(0, ...results.map((r) => r.code));
1014
+ if (exitCode === 0) {
1015
+ success("Dev loop stopped cleanly.");
1016
+ }
1017
+ return exitCode;
1018
+ }
1019
+ function spawnSdkWatcher(projectRoot, env) {
1020
+ const child = spawn(
1021
+ process.execPath,
1022
+ [process.argv[1], "module", "dev", "--dir", projectRoot],
1023
+ {
1024
+ cwd: projectRoot,
1025
+ env,
1026
+ stdio: ["ignore", "pipe", "pipe"]
1027
+ }
1028
+ );
1029
+ return child;
1030
+ }
1031
+ function spawnNextDev(projectRoot, env, port) {
1032
+ const portArgs = port !== void 0 ? ["--", "--port", String(port)] : [];
1033
+ const hasDevNextScript = hasNpmScript(projectRoot, "dev:next");
1034
+ const [cmd, args] = hasDevNextScript ? ["npm", ["run", "dev:next", ...portArgs]] : ["npx", ["next", "dev", ...port !== void 0 ? ["--port", String(port)] : []]];
1035
+ const child = spawn(cmd, args, {
1036
+ cwd: projectRoot,
1037
+ env,
1038
+ stdio: ["ignore", "pipe", "pipe"],
1039
+ // Windows requires `shell: true` for `npm` / `npx` because they're
1040
+ // `.cmd` shims, not real binaries.
1041
+ shell: process.platform === "win32"
1042
+ });
1043
+ return child;
1044
+ }
1045
+ function hasNpmScript(projectRoot, scriptName) {
1046
+ try {
1047
+ const pkgPath = `${projectRoot}/package.json`;
1048
+ if (!existsSync(pkgPath)) return false;
1049
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
1050
+ return typeof pkg.scripts?.[scriptName] === "string";
1051
+ } catch {
1052
+ return false;
1053
+ }
1054
+ }
1055
+ function pipeWithPrefix(src, dst, prefix) {
1056
+ if (!src) return;
1057
+ let leftover = "";
1058
+ src.on("data", (chunk) => {
1059
+ const text4 = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
1060
+ const combined = leftover + text4;
1061
+ const lines = combined.split("\n");
1062
+ leftover = lines.pop() ?? "";
1063
+ for (const line of lines) {
1064
+ dst.write(`${prefix}${line}
1065
+ `);
1066
+ }
1067
+ });
1068
+ src.on("end", () => {
1069
+ if (leftover.length > 0) {
1070
+ dst.write(`${prefix}${leftover}
1071
+ `);
1072
+ }
1073
+ });
1074
+ }
1075
+ function maskedToken(token) {
1076
+ if (token.length <= 12) return "****";
1077
+ const prefix = token.slice(0, 4);
1078
+ const tail = token.slice(-4);
1079
+ return `${prefix}\u2026${tail}`;
1080
+ }
1081
+ async function resolveModuleApiKey(slug, entry, apiUrl, cliBearer) {
1082
+ if (process.env.DASHWISE_API_KEY) return null;
1083
+ if (!entry) return null;
1084
+ if (entry.apiKey) return null;
1085
+ if (!entry.installationId) return null;
1086
+ info(
1087
+ `No usable ${code("dwk_")} API key cached for ${code(slug)} \u2014 minting a fresh one against dev install ${entry.installationId} (a stale CLI can cache an empty key)\u2026`
1088
+ );
1089
+ let minted;
1090
+ try {
1091
+ minted = await createApiKey(
1092
+ entry.installationId,
1093
+ { name: "local-dev" },
1094
+ { baseUrl: apiUrl, auth: { token: cliBearer } }
1095
+ );
1096
+ } catch (err) {
1097
+ if (err instanceof ApiError && err.code === "INSTALLATION_NOT_FOUND") {
1098
+ warn(
1099
+ `Could not upgrade credentials for ${code(slug)}: its dev install no longer exists. Run ${code(`dashwise init ${slug}`)} to re-provision (a clean re-register; files are preserved).`
1100
+ );
1101
+ return null;
1102
+ }
1103
+ const reason = err instanceof ApiError ? `${err.code}: ${err.message}` : err.message;
1104
+ warn(
1105
+ `Could not upgrade credentials for ${code(slug)} (${reason}). Data-plane calls may fail; run ${code(`dashwise init ${slug}`)} to refresh (files are preserved).`
1106
+ );
1107
+ return null;
1108
+ }
1109
+ const upgraded = {
1110
+ apiKey: minted.raw_key,
1111
+ apiKeyId: minted.api_key.id,
1112
+ installationId: entry.installationId,
1113
+ expiresAt: minted.api_key.expires_at ?? entry.expiresAt
1114
+ };
1115
+ try {
1116
+ upsertModuleEntry(slug, upgraded);
1117
+ } catch (err) {
1118
+ warn(
1119
+ `Minted a new API key for ${code(slug)} but failed to cache it: ${err.message}
1120
+ API key (save manually): ${minted.raw_key}`
1121
+ );
1122
+ }
1123
+ success(`Upgraded ${code(slug)} to a dwk_ API key (id=${minted.api_key.id}).`);
1124
+ return upgraded;
1125
+ }
1126
+ function devSessionHandoffEnv(session) {
1127
+ const secret = session.handoff_secret;
1128
+ if (typeof secret !== "string" || secret.length === 0) return {};
1129
+ return {
1130
+ DASHWISE_DEV_SESSION_ID: session.id,
1131
+ DASHWISE_DEV_HANDOFF_SECRET: secret
1132
+ };
1133
+ }
1134
+ function computeCallbackOrigins(port, extra) {
1135
+ const def = `http://localhost:${port}`;
1136
+ return Array.from(/* @__PURE__ */ new Set([def, ...extra]));
1137
+ }
1138
+ async function resolveDevPort(requested) {
1139
+ if (requested !== void 0) {
1140
+ if (await isPortFree(requested)) return requested;
1141
+ const suggestion = await findFirstFreePort(requested + 1, PORT_PROBE_LIMIT);
1142
+ const suggestionHint = suggestion !== null ? `Try \`dashwise dev --port ${suggestion}\`.` : `No free port found between ${requested + 1} and ${requested + PORT_PROBE_LIMIT} either \u2014 check what's listening with \`lsof -i :${requested}\`.`;
1143
+ throw new Error(
1144
+ `Port ${requested} is already in use. ${suggestionHint}`
1145
+ );
1146
+ }
1147
+ const found = await findFirstFreePort(DEFAULT_NEXT_PORT, PORT_PROBE_LIMIT);
1148
+ if (found === null) {
1149
+ throw new Error(
1150
+ `No free port found between ${DEFAULT_NEXT_PORT} and ${DEFAULT_NEXT_PORT + PORT_PROBE_LIMIT - 1}. Free a port (e.g. \`lsof -i :${DEFAULT_NEXT_PORT}\`) or pass \`--port <N>\` explicitly.`
1151
+ );
1152
+ }
1153
+ return found;
1154
+ }
1155
+ async function isPortFree(port) {
1156
+ const { createServer } = await import('net');
1157
+ return new Promise((resolve13) => {
1158
+ const server = createServer();
1159
+ server.once("error", () => {
1160
+ resolve13(false);
1161
+ });
1162
+ server.once("listening", () => {
1163
+ server.close(() => resolve13(true));
1164
+ });
1165
+ server.listen(port, "0.0.0.0");
1166
+ });
1167
+ }
1168
+ async function findFirstFreePort(start, limit) {
1169
+ for (let i = 0; i < limit; i++) {
1170
+ const port = start + i;
1171
+ if (await isPortFree(port)) return port;
1172
+ }
1173
+ return null;
1174
+ }
1175
+ async function resolveTargetWorkspace(apiUrl, apiToken, flag) {
1176
+ const workspaces = await apiRequest(
1177
+ "GET",
1178
+ "/api/workspaces",
1179
+ void 0,
1180
+ { baseUrl: apiUrl, auth: { token: apiToken } }
1181
+ );
1182
+ if (workspaces.length === 0) {
1183
+ throw new Error(
1184
+ "No workspaces found for your account. Create a workspace at the DashWise dashboard first."
1185
+ );
1186
+ }
1187
+ if (flag === void 0) {
1188
+ if (workspaces.length === 1) {
1189
+ return workspaces[0];
1190
+ }
1191
+ const list = workspaces.map((w) => ` - ${w.slug} (id=${w.id}, name="${w.name}")`).join("\n");
1192
+ throw new Error(
1193
+ `You have ${workspaces.length} workspaces. Re-run with --workspace <slug-or-id> to pick one:
1194
+ ${list}`
1195
+ );
1196
+ }
1197
+ const asNumber = Number.parseInt(flag, 10);
1198
+ const isNumeric = !Number.isNaN(asNumber) && String(asNumber) === flag;
1199
+ const match = isNumeric ? workspaces.find((w) => w.id === asNumber) : workspaces.find((w) => w.slug === flag);
1200
+ if (!match) {
1201
+ const list = workspaces.map((w) => ` - ${w.slug} (id=${w.id})`).join("\n");
1202
+ throw new Error(
1203
+ `No workspace matching --workspace=${flag}. Available:
1204
+ ${list}`
1205
+ );
1206
+ }
1207
+ return match;
1208
+ }
1209
+ async function registerDevSession(apiUrl, apiToken, body) {
1210
+ return apiRequest(
1211
+ "POST",
1212
+ "/api/auth/dev/sessions",
1213
+ body,
1214
+ { baseUrl: apiUrl, auth: { token: apiToken } }
1215
+ );
1216
+ }
1217
+ async function revokeDevSessionBestEffort(apiUrl, apiToken, id) {
1218
+ await apiRequest(
1219
+ "DELETE",
1220
+ `/api/auth/dev/sessions/${encodeURIComponent(id)}`,
1221
+ void 0,
1222
+ { baseUrl: apiUrl, auth: { token: apiToken } }
1223
+ );
1224
+ }
1225
+ var DEFAULT_NEXT_PORT, PREFIX_SDK, PREFIX_NEXT, PREFIX_POLL, DEFAULT_REMOTE_POLL_INTERVAL_SEC, MIN_REMOTE_POLL_INTERVAL_SEC, PORT_PROBE_LIMIT;
1226
+ var init_dev = __esm({
1227
+ "src/commands/dev.ts"() {
1228
+ init_api_client();
1229
+ init_connect_status();
1230
+ init_config();
1231
+ init_manifest_io();
1232
+ init_manifest_poll();
1233
+ init_output();
1234
+ DEFAULT_NEXT_PORT = 3e3;
1235
+ PREFIX_SDK = `\x1B[36m[sdk]\x1B[0m `;
1236
+ PREFIX_NEXT = `\x1B[35m[next]\x1B[0m `;
1237
+ PREFIX_POLL = `\x1B[33m[poll]\x1B[0m `;
1238
+ DEFAULT_REMOTE_POLL_INTERVAL_SEC = 5;
1239
+ MIN_REMOTE_POLL_INTERVAL_SEC = 1;
1240
+ PORT_PROBE_LIMIT = 10;
1241
+ }
1242
+ });
1243
+
1244
+ // src/lib/scaffold.ts
1245
+ function isDeployTarget(value) {
1246
+ return DEPLOY_TARGETS.includes(value);
1247
+ }
1248
+ function manifestJson(opts) {
1249
+ const kind = opts.kind ?? "hand_authored";
1250
+ const manifest = {
1251
+ schema_version: 2,
1252
+ module: {
1253
+ slug: opts.slug,
1254
+ name: opts.name,
1255
+ version: "0.0.1"
1256
+ },
1257
+ module_kind: kind,
1258
+ owns: {
1259
+ tables: [
1260
+ {
1261
+ // NOTE: no `name` key here — the backend manifest validator
1262
+ // (POST /api/installations/dev) is strict and rejects unrecognized
1263
+ // table keys, and it derives the display name from the slug. A
1264
+ // `name` here would fail provisioning's table materialization (#4).
1265
+ slug: "items",
1266
+ fields: [
1267
+ { slug: "title", type: "text" },
1268
+ { slug: "done", type: "boolean" }
1269
+ ]
1270
+ }
1271
+ ]
1272
+ }
1273
+ };
1274
+ if (kind === "custom") {
1275
+ manifest.runtime = {
1276
+ type: "nextjs",
1277
+ kind: "machine",
1278
+ machine_size: "shared-cpu-1x",
1279
+ server_actions: true
1280
+ };
1281
+ }
1282
+ if (opts.repositoryUrl) {
1283
+ manifest.repository = { type: "git", url: opts.repositoryUrl };
1284
+ }
1285
+ return JSON.stringify(manifest, null, 2) + "\n";
1286
+ }
1287
+ function packageJson(opts) {
1288
+ return JSON.stringify(
1289
+ {
1290
+ name: opts.slug,
1291
+ version: "0.0.1",
1292
+ private: true,
1293
+ type: "module",
1294
+ scripts: {
1295
+ dev: "dashwise module dev",
1296
+ "dev:next": "next dev",
1297
+ "dashwise:generate": "dashwise module generate",
1298
+ build: "next build",
1299
+ start: "next start"
1300
+ },
1301
+ dependencies: {
1302
+ "@dashai/sdk": "^2.3.0",
1303
+ next: "^15.0.0",
1304
+ react: "^19.0.0",
1305
+ "react-dom": "^19.0.0"
1306
+ },
1307
+ devDependencies: {
1308
+ "@types/node": "^22.0.0",
1309
+ "@types/react": "^19.0.0",
1310
+ "@types/react-dom": "^19.0.0",
1311
+ typescript: "^5.5.0"
1312
+ }
1313
+ },
1314
+ null,
1315
+ 2
1316
+ ) + "\n";
1317
+ }
1318
+ function tsconfigJson() {
1319
+ return JSON.stringify(
1320
+ {
1321
+ compilerOptions: {
1322
+ target: "ES2022",
1323
+ lib: ["dom", "dom.iterable", "ES2022"],
1324
+ module: "ESNext",
1325
+ moduleResolution: "Bundler",
1326
+ jsx: "preserve",
1327
+ strict: true,
1328
+ skipLibCheck: true,
1329
+ resolveJsonModule: true,
1330
+ esModuleInterop: true,
1331
+ isolatedModules: true,
1332
+ allowJs: false,
1333
+ noEmit: true,
1334
+ incremental: true,
1335
+ plugins: [{ name: "next" }],
1336
+ paths: { "@/*": ["./*"] }
1337
+ },
1338
+ include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
1339
+ exclude: ["node_modules"]
1340
+ },
1341
+ null,
1342
+ 2
1343
+ ) + "\n";
1344
+ }
1345
+ function nextConfig() {
1346
+ return `/** @type {import("next").NextConfig} */
1347
+ //
1348
+ // Phase 2 two-var contract: the deploy env supplies exactly
1349
+ // DASHWISE_API_URL + DASHWISE_API_KEY. The browser needs the API URL
1350
+ // too (the SDK's <AuthProvider> calls /api/auth/sessions/me on it), so
1351
+ // we DERIVE NEXT_PUBLIC_DASHWISE_API_URL here from DASHWISE_API_URL at
1352
+ // build/start time \u2014 you never set the NEXT_PUBLIC_ var yourself. If
1353
+ // you already have one set (e.g. a custom setup), it wins.
1354
+ const apiUrl = process.env.DASHWISE_API_URL;
1355
+
1356
+ const nextConfig = {
1357
+ // Required for the platform-hosted (per-install Lambda) runtime: the
1358
+ // injected wrapper probes [.next/standalone/server.js, server.js] and
1359
+ // exit(1)s the cold start if neither exists. Without this the built
1360
+ // Lambda FATALs. Harmless for local \`next dev\` / \`next start\`.
1361
+ output: 'standalone',
1362
+ typescript: { ignoreBuildErrors: true },
1363
+ eslint: { ignoreDuringBuilds: true },
1364
+ env: {
1365
+ NEXT_PUBLIC_DASHWISE_API_URL:
1366
+ process.env.NEXT_PUBLIC_DASHWISE_API_URL ?? apiUrl,
1367
+ },
1368
+ };
1369
+ export default nextConfig;
1370
+ `;
1371
+ }
1372
+ function nextEnvDts() {
1373
+ return `/// <reference types="next" />
1374
+ /// <reference types="next/image-types/global" />
679
1375
  `;
680
1376
  }
681
1377
  function gitignore() {
@@ -1271,8 +1967,20 @@ function appApiAuthSignInTs() {
1271
1967
  // 302 back to callback_url?code=<one-time>. The /api/auth/callback route
1272
1968
  // exchanges that code for a session cookie.
1273
1969
  //
1274
- // No dev-vs-prod branching, no installation id, no dev-session id \u2014 the
1275
- // single dwk_ key drives both local dev and production.
1970
+ // ZERO-CLICK LOCAL DEV (CLI \u2265 0.16.0 + backend \u2265 2026-07-15): when
1971
+ // \`dashwise dev\` injected BOTH DASHWISE_DEV_SESSION_ID and
1972
+ // DASHWISE_DEV_HANDOFF_SECRET, this route first presents the handoff
1973
+ // secret to the backend SERVER-SIDE (POST /api/auth/dev/sso/authorize)
1974
+ // and 302s the browser straight to the returned callback URL \u2014 the user
1975
+ // NEVER sees a login page and no platform browser session is needed. On
1976
+ // ANY failure it logs one server-side warn line and falls back to the
1977
+ // browser flow above unchanged (that path still matters for signing in
1978
+ // as a DIFFERENT workspace member and for stale dev sessions). The
1979
+ // secret is server-env-only: never in a URL, response body, or the
1980
+ // client bundle.
1981
+ //
1982
+ // No dev-vs-prod branching for the browser flow \u2014 the single dwk_ key
1983
+ // drives both local dev and production.
1276
1984
  import { NextResponse } from 'next/server';
1277
1985
  import { getPublicOrigin } from '@/lib/request-origin';
1278
1986
 
@@ -1292,6 +2000,30 @@ export async function GET(request: Request) {
1292
2000
  status: 500,
1293
2001
  });
1294
2002
  }
2003
+
2004
+ // \`getPublicOrigin\` reads \`X-Forwarded-Host\` when present so URLs
2005
+ // point at the public domain, not the internal Node origin behind
2006
+ // reverse proxies (Railway/Vercel/Fly).
2007
+ const moduleOrigin = getPublicOrigin(request);
2008
+
2009
+ // \u2500\u2500 Zero-click dev handoff (server-side; runs BEFORE the dwk_-key
2010
+ // guard \u2014 the authorize endpoint authenticates with the handoff
2011
+ // secret, not the key). Both env vars must be present: \`dashwise dev\`
2012
+ // injects them together, and only when the backend issued a secret.
2013
+ const devSessionId = process.env.DASHWISE_DEV_SESSION_ID;
2014
+ const devHandoffSecret = process.env.DASHWISE_DEV_HANDOFF_SECRET;
2015
+ if (devSessionId && devHandoffSecret) {
2016
+ const callbackUrl = await tryDevHandoff(
2017
+ apiBaseUrl,
2018
+ devSessionId,
2019
+ devHandoffSecret,
2020
+ returnTo,
2021
+ moduleOrigin,
2022
+ );
2023
+ if (callbackUrl) return NextResponse.redirect(callbackUrl);
2024
+ // null \u2192 warned server-side already; fall through to the browser flow.
2025
+ }
2026
+
1295
2027
  if (!apiKey) {
1296
2028
  return new NextResponse(
1297
2029
  'DASHWISE_API_KEY must be set in the environment. Run \`dashwise dev\` ' +
@@ -1301,12 +2033,9 @@ export async function GET(request: Request) {
1301
2033
  }
1302
2034
 
1303
2035
  // Absolute callback URL so the backend knows which host to bounce back
1304
- // to. \`getPublicOrigin\` reads \`X-Forwarded-Host\` when present so the
1305
- // URL points at the public domain, not the internal Node origin behind
1306
- // reverse proxies (Railway/Vercel/Fly). The callback path is fixed at
1307
- // /api/auth/callback by SDK convention; we tuck \`return_to\` onto it so
1308
- // the callback route can resume the user's original destination.
1309
- const moduleOrigin = getPublicOrigin(request);
2036
+ // to. The callback path is fixed at /api/auth/callback by SDK
2037
+ // convention; we tuck \`return_to\` onto it so the callback route can
2038
+ // resume the user's original destination.
1310
2039
  const callback = new URL('/api/auth/callback', moduleOrigin);
1311
2040
  callback.searchParams.set('return_to', returnTo);
1312
2041
 
@@ -1359,6 +2088,74 @@ export async function GET(request: Request) {
1359
2088
  }
1360
2089
  return NextResponse.redirect(body.redirect_url);
1361
2090
  }
2091
+
2092
+ /**
2093
+ * Zero-click dev sign-in (CLI \u2265 0.16.0 + backend \u2265 2026-07-15).
2094
+ *
2095
+ * SERVER-SIDE ONLY: presents the dev session's one-time handoff secret
2096
+ * to \`POST /api/auth/dev/sso/authorize\`. On 200 the backend mints a
2097
+ * single-use 60s exchange code for the DEV'S OWN user and returns
2098
+ * \`{ callback_url }\` (= \`<origin>/api/auth/callback?code=\u2026&return_to=\u2026\`)
2099
+ * \u2014 the caller 302s the browser straight there; the normal callback
2100
+ * route exchanges the code exactly like the browser flow.
2101
+ *
2102
+ * Returns the callback URL, or \`null\` on ANY failure (404
2103
+ * DEV_SESSION_NOT_FOUND, 401 DEV_HANDOFF_INVALID, 400 INVALID_RETURN_TO,
2104
+ * 403 WORKSPACE_NOT_MEMBER, network) after logging a one-line warn \u2014
2105
+ * the caller then falls back to the browser SSO flow, which remains the
2106
+ * path for signing in as a DIFFERENT workspace member and for stale dev
2107
+ * sessions. The secret must never appear in a URL or response body.
2108
+ */
2109
+ async function tryDevHandoff(
2110
+ apiBaseUrl: string,
2111
+ devSessionId: string,
2112
+ devHandoffSecret: string,
2113
+ returnTo: string,
2114
+ moduleOrigin: string,
2115
+ ): Promise<string | null> {
2116
+ // Same root-relative-only rule as the callback's safeReturnTo \u2014 an
2117
+ // absolute/protocol-relative return_to must not steer the handoff.
2118
+ const path =
2119
+ returnTo.startsWith('/') && !returnTo.startsWith('//') ? returnTo : '/';
2120
+ // \`return_to\` is the user's ABSOLUTE destination on this module's own
2121
+ // (loopback) origin; the backend validates origin allowlist + loopback
2122
+ // and builds the callback URL from it.
2123
+ const destination = new URL(path, moduleOrigin);
2124
+ let res: Response;
2125
+ try {
2126
+ res = await fetch(\`\${apiBaseUrl}/api/auth/dev/sso/authorize\`, {
2127
+ method: 'POST',
2128
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
2129
+ body: JSON.stringify({
2130
+ dev_session_id: devSessionId,
2131
+ handoff_secret: devHandoffSecret,
2132
+ return_to: destination.toString(),
2133
+ }),
2134
+ });
2135
+ } catch (err) {
2136
+ console.warn(
2137
+ \`[auth] dev handoff failed (\${(err as Error).message}) \u2014 falling back to browser SSO\`,
2138
+ );
2139
+ return null;
2140
+ }
2141
+ if (!res.ok) {
2142
+ const body = (await res.json().catch(() => null)) as { code?: string } | null;
2143
+ console.warn(
2144
+ \`[auth] dev handoff rejected (\${body?.code ?? \`HTTP \${res.status}\`}) \u2014 falling back to browser SSO\`,
2145
+ );
2146
+ return null;
2147
+ }
2148
+ const body = (await res.json().catch(() => null)) as
2149
+ | { callback_url?: string }
2150
+ | null;
2151
+ if (!body?.callback_url) {
2152
+ console.warn(
2153
+ '[auth] dev handoff response missing callback_url \u2014 falling back to browser SSO',
2154
+ );
2155
+ return null;
2156
+ }
2157
+ return body.callback_url;
2158
+ }
1362
2159
  `;
1363
2160
  }
1364
2161
  function appApiAuthCallbackTs() {
@@ -1367,7 +2164,14 @@ function appApiAuthCallbackTs() {
1367
2164
  // SSO flow failed. Two paths:
1368
2165
  //
1369
2166
  // 1. Success path: exchange \`?code\` for a session cookie via
1370
- // \`exchangeCode()\`, then redirect to \`return_to\`.
2167
+ // \`exchangeCode()\`, then redirect to \`return_to\`. SDK \u2265 2.3.0:
2168
+ // the result's \`cookieWritten\` flag surfaces a cookie store that
2169
+ // rejected the write \u2014 without it the exchange "succeeds", the
2170
+ // browser has no session, and the user silently bounces back to
2171
+ // sign-in. We render /sign-in-error?code=cookie_write_failed
2172
+ // instead. \`requestUrl\` lets the SDK fall back to the actual
2173
+ // request protocol for the cookie's Secure attribute when no
2174
+ // x-forwarded-proto header is present (plain-http \`next start\`).
1371
2175
  // 2. Error path: SSO returned \`?error=\` \u2014 redirect to the polished
1372
2176
  // sign-in-error page so the user sees a real error UI instead of
1373
2177
  // a raw 4xx page. The error code maps to known SSO error codes
@@ -1404,7 +2208,17 @@ export async function GET(request: Request) {
1404
2208
  return NextResponse.redirect(target);
1405
2209
  }
1406
2210
  try {
1407
- await exchangeCode(code); // sets the dashwise.session cookie
2211
+ // Sets the dashwise.session cookie. \`requestUrl\` = the Secure-attr
2212
+ // protocol fallback when no x-forwarded-proto header is present.
2213
+ const result = await exchangeCode(code, { requestUrl: request.url });
2214
+ if (!result.cookieWritten) {
2215
+ // The exchange succeeded but the cookie store rejected the write \u2014
2216
+ // the browser has NO session. Surface it instead of redirecting
2217
+ // into an unauthenticated loop (SDK \u2265 2.3.0).
2218
+ const target = new URL('/sign-in-error', publicOrigin);
2219
+ target.searchParams.set('code', 'cookie_write_failed');
2220
+ return NextResponse.redirect(target);
2221
+ }
1408
2222
  } catch (err) {
1409
2223
  const target = new URL('/sign-in-error', publicOrigin);
1410
2224
  target.searchParams.set('code', 'exchange_failed');
@@ -1441,7 +2255,12 @@ import { signOut } from '@dashai/sdk/auth/server';
1441
2255
  import { getPublicOrigin } from '@/lib/request-origin';
1442
2256
 
1443
2257
  export async function POST(request: Request) {
1444
- await signOut();
2258
+ // Pass requestUrl so the cookie-CLEAR resolves its Secure attribute the
2259
+ // SAME way the sign-in SET did (exchangeCode also gets request.url) \u2014
2260
+ // otherwise a plain-http localhost dev server sets a non-Secure cookie
2261
+ // but tries to clear a Secure one, which the browser ignores, leaving a
2262
+ // stale (backend-revoked, but client-present) cookie after sign-out.
2263
+ await signOut({ requestUrl: request.url });
1445
2264
  // Build the redirect against the public origin so we don't land
1446
2265
  // the user on \`http://localhost:<PORT>/\` when running behind a
1447
2266
  // reverse proxy (Railway/Vercel/Fly).
@@ -1528,7 +2347,7 @@ function packageJsonCustom(opts) {
1528
2347
  start: "next start"
1529
2348
  },
1530
2349
  dependencies: {
1531
- "@dashai/sdk": "^2.0.0",
2350
+ "@dashai/sdk": "^2.3.0",
1532
2351
  next: "^15.0.0",
1533
2352
  react: "^19.0.0",
1534
2353
  "react-dom": "^19.0.0",
@@ -1689,15 +2508,40 @@ function globalsCss() {
1689
2508
  --chart-5: oklch(0.67 0.2 15);
1690
2509
  }
1691
2510
 
1692
- /* Wire the chart tokens into Tailwind's color namespace so \`bg-chart-1\`,
1693
- \`text-chart-2\`, \`border-l-chart-3\`, \u2026 resolve. (\`@import "shadcn/tailwind.css"\`
1694
- already maps the core tokens like \`--primary\`; this covers the ramp.) */
2511
+ /* Wire EVERY design token into Tailwind's namespace. In Tailwind 4,
2512
+ utilities like \`bg-primary\` / \`text-muted-foreground\` / \`rounded-lg\`
2513
+ only exist if @theme defines the matching \`--color-*\` / \`--radius-*\`
2514
+ variable \u2014 the \`shadcn\` npm package's tailwind.css maps NONE of them
2515
+ (it ships only keyframes + variants), so without this block every
2516
+ semantic-token utility silently compiles to nothing. */
1695
2517
  @theme inline {
2518
+ --color-background: var(--background);
2519
+ --color-foreground: var(--foreground);
2520
+ --color-card: var(--card);
2521
+ --color-card-foreground: var(--card-foreground);
2522
+ --color-popover: var(--popover);
2523
+ --color-popover-foreground: var(--popover-foreground);
2524
+ --color-primary: var(--primary);
2525
+ --color-primary-foreground: var(--primary-foreground);
2526
+ --color-secondary: var(--secondary);
2527
+ --color-secondary-foreground: var(--secondary-foreground);
2528
+ --color-muted: var(--muted);
2529
+ --color-muted-foreground: var(--muted-foreground);
2530
+ --color-accent: var(--accent);
2531
+ --color-accent-foreground: var(--accent-foreground);
2532
+ --color-destructive: var(--destructive);
2533
+ --color-border: var(--border);
2534
+ --color-input: var(--input);
2535
+ --color-ring: var(--ring);
1696
2536
  --color-chart-1: var(--chart-1);
1697
2537
  --color-chart-2: var(--chart-2);
1698
2538
  --color-chart-3: var(--chart-3);
1699
2539
  --color-chart-4: var(--chart-4);
1700
2540
  --color-chart-5: var(--chart-5);
2541
+ --radius-sm: calc(var(--radius) - 4px);
2542
+ --radius-md: calc(var(--radius) - 2px);
2543
+ --radius-lg: var(--radius);
2544
+ --radius-xl: calc(var(--radius) + 4px);
1701
2545
  }
1702
2546
 
1703
2547
  * {
@@ -1897,6 +2741,90 @@ export function ThemeProvider({
1897
2741
  }
1898
2742
  `;
1899
2743
  }
2744
+ function appErrorTsx() {
2745
+ return `"use client";
2746
+
2747
+ /**
2748
+ * Root error boundary. Next.js renders this when anything below the
2749
+ * layout throws during render or data loading.
2750
+ *
2751
+ * - \`error.digest\` is the server-side correlation id \u2014 surface it so a
2752
+ * bug report can be matched to the runtime logs.
2753
+ * - \`reset()\` re-renders the segment; for transient failures (network
2754
+ * blip, expired session mid-flight) a retry often just works.
2755
+ */
2756
+ import { Button } from "@/components/ui/button";
2757
+ import {
2758
+ Card,
2759
+ CardContent,
2760
+ CardDescription,
2761
+ CardHeader,
2762
+ CardTitle,
2763
+ } from "@/components/ui/card";
2764
+
2765
+ export default function Error({
2766
+ error,
2767
+ reset,
2768
+ }: {
2769
+ error: Error & { digest?: string };
2770
+ reset: () => void;
2771
+ }) {
2772
+ return (
2773
+ <div className="flex min-h-screen items-center justify-center bg-background p-6">
2774
+ <Card className="w-full max-w-md">
2775
+ <CardHeader>
2776
+ <CardTitle>Something went wrong</CardTitle>
2777
+ <CardDescription>
2778
+ An unexpected error occurred while rendering this page.
2779
+ </CardDescription>
2780
+ </CardHeader>
2781
+ <CardContent className="space-y-4">
2782
+ {error.digest ? (
2783
+ <p className="text-sm text-muted-foreground">
2784
+ Error digest:{" "}
2785
+ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
2786
+ {error.digest}
2787
+ </code>
2788
+ </p>
2789
+ ) : null}
2790
+ <Button onClick={() => reset()}>Try again</Button>
2791
+ </CardContent>
2792
+ </Card>
2793
+ </div>
2794
+ );
2795
+ }
2796
+ `;
2797
+ }
2798
+ function seedMjs() {
2799
+ return `/**
2800
+ * seed.mjs \u2014 OPTIONAL seed/maintenance script template.
2801
+ *
2802
+ * Run it with the module's DashWise env injected (DASHWISE_API_URL +
2803
+ * DASHWISE_API_KEY resolved like \`dashwise query\`: explicit env \u2192
2804
+ * .env.local \u2192 the dwk_ key cached by your CLI login):
2805
+ *
2806
+ * dashwise run -- node seed.mjs
2807
+ *
2808
+ * (Requires \`dashwise module generate\` to have run at least once so
2809
+ * \`@dashai/generated\` exists \u2014 \`dashwise dev\` does this for you.)
2810
+ *
2811
+ * \u26A0 PUBLISH WARNING: \`dashwise module publish\` packs the ENTIRE project
2812
+ * tree (it does NOT read .gitignore). Delete this file \u2014 or publish with
2813
+ * \`--exclude seed.mjs\` \u2014 before publishing, so seed data/logic doesn't
2814
+ * ship inside your module bundle.
2815
+ */
2816
+
2817
+ // Uncomment to use (the scaffold's example \`items\` table):
2818
+ //
2819
+ // import { db } from '@dashai/generated';
2820
+ //
2821
+ // const created = await db.items.create({ title: 'Seeded item', done: false });
2822
+ // console.log('created items row', created.id);
2823
+ //
2824
+ // const rows = await db.items.list({ limit: 10 });
2825
+ // console.log('items now:', rows.length);
2826
+ `;
2827
+ }
1900
2828
  function appSignInErrorPageTsx() {
1901
2829
  return `import Link from 'next/link';
1902
2830
  import { AlertTriangle, RefreshCw } from 'lucide-react';
@@ -1920,6 +2848,8 @@ const ERROR_MESSAGES: Record<string, string> = {
1920
2848
  'The callback URL was missing the expected exchange code. This usually means the SSO flow was interrupted.',
1921
2849
  exchange_failed:
1922
2850
  "The exchange code couldn't be redeemed for a session. The code may have expired (they're single-use and short-lived) \u2014 try signing in again.",
2851
+ cookie_write_failed:
2852
+ 'Sign-in completed, but the session cookie could not be written, so the session would have been lost. This means exchangeCode ran outside a Route Handler / Server Action context (Next can only set cookies there) or next/headers was unavailable \u2014 verify /api/auth/callback is a standard route handler and check the module server logs.',
1923
2853
  };
1924
2854
 
1925
2855
  interface SignInErrorPageProps {
@@ -2417,15 +3347,23 @@ async function moduleInitCommand(slugArg, options) {
2417
3347
  }
2418
3348
  const name = options.name ?? humanizeSlug(slug);
2419
3349
  const dir = resolve(options.dir ?? slug);
2420
- if (existsSync(dir)) {
3350
+ const manifestExists = existsSync(join(dir, "module.json"));
3351
+ if (options.provisionOnly && !manifestExists) {
3352
+ fail(
3353
+ `Provision-only mode needs an existing ${code("module.json")} in ${code(dir)} \u2014 there is nothing to provision against. Run ${code(`dashwise init ${slug}`)} to scaffold first.`
3354
+ );
3355
+ return 1;
3356
+ }
3357
+ if (!options.provisionOnly && existsSync(dir)) {
2421
3358
  const entries = readdirSync(dir);
2422
- if (entries.length > 0 && !options.force) {
3359
+ if (entries.length > 0 && !options.force && !manifestExists) {
2423
3360
  fail(
2424
3361
  `Directory ${code(dir)} is not empty. Pass ${code("--force")} to scaffold anyway (existing files will be overwritten).`
2425
3362
  );
2426
3363
  return 1;
2427
3364
  }
2428
3365
  }
3366
+ const writeMode = options.provisionOnly ? "none" : manifestExists && !options.force ? "missing-only" : "all";
2429
3367
  if (options.simple && options.custom) {
2430
3368
  fail(
2431
3369
  `Cannot pass both ${code("--simple")} and ${code("--custom")} \u2014 they're mutually exclusive. ${code("--simple")} selects the legacy minimal scaffold; the default (no flag) is the full Next.js scaffold (formerly ${code("--custom")}).`
@@ -2464,7 +3402,11 @@ async function moduleInitCommand(slugArg, options) {
2464
3402
  // Example data-plane route (both flavors) — exercises the generated
2465
3403
  // SDK against the seeded `items` table. Ships for simple + custom
2466
3404
  // since both produce `@dashai/generated`.
2467
- ["app/api/items/route.ts", appApiItemsRouteTs()]
3405
+ ["app/api/items/route.ts", appApiItemsRouteTs()],
3406
+ // C12/D5 (Wave 2) — commented-out seed-script template. Pairs with
3407
+ // `dashwise run -- node seed.mjs`; carries the publish-packs-
3408
+ // everything warning so it never ships inside a bundle by accident.
3409
+ ["seed.mjs", seedMjs()]
2468
3410
  ];
2469
3411
  if (kind === "custom") {
2470
3412
  files.push(
@@ -2480,6 +3422,10 @@ async function moduleInitCommand(slugArg, options) {
2480
3422
  ["components/ui/card.tsx", componentsUiCardTsx()],
2481
3423
  ["app/layout.tsx", appLayoutCustomTsx(scaffoldOpts)],
2482
3424
  ["app/page.tsx", appPageCustomTsx(scaffoldOpts)],
3425
+ // C10 (Wave 2) — root error boundary: branded card, error.digest,
3426
+ // reset() retry, semantic tokens (which resolve now that
3427
+ // globals.css maps them in @theme).
3428
+ ["app/error.tsx", appErrorTsx()],
2483
3429
  ["app/dashboard/page.tsx", appDashboardPageTsx()],
2484
3430
  ["app/sign-in-error/page.tsx", appSignInErrorPageTsx()],
2485
3431
  ["app/api/auth/sign-in/route.ts", appApiAuthSignInTs()],
@@ -2509,17 +3455,36 @@ async function moduleInitCommand(slugArg, options) {
2509
3455
  break;
2510
3456
  }
2511
3457
  }
2512
- for (const [relPath, content] of files) {
2513
- const absPath = join(dir, relPath);
2514
- const parentDir = join(absPath, "..");
2515
- if (!existsSync(parentDir)) {
2516
- mkdirSync(parentDir, { recursive: true });
3458
+ let written = 0;
3459
+ let skipped = 0;
3460
+ if (writeMode !== "none") {
3461
+ for (const [relPath, content] of files) {
3462
+ const absPath = join(dir, relPath);
3463
+ if (writeMode === "missing-only" && existsSync(absPath)) {
3464
+ skipped++;
3465
+ continue;
3466
+ }
3467
+ const parentDir = join(absPath, "..");
3468
+ if (!existsSync(parentDir)) {
3469
+ mkdirSync(parentDir, { recursive: true });
3470
+ }
3471
+ writeFileSync(absPath, content, "utf-8");
3472
+ written++;
2517
3473
  }
2518
- writeFileSync(absPath, content, "utf-8");
2519
3474
  }
2520
- success(
2521
- `Scaffolded ${code(slug)} (${kind === "custom" ? "custom Next.js" : "simple module"}) in ${code(dir)}`
2522
- );
3475
+ if (writeMode === "none") {
3476
+ info(
3477
+ `Existing scaffold in ${code(dir)} left untouched (provision-only) \u2014 re-provisioning the backend dev install.`
3478
+ );
3479
+ } else if (writeMode === "missing-only") {
3480
+ success(
3481
+ `Existing module project in ${code(dir)} \u2014 wrote ${written} missing file(s), preserved ${skipped} existing file(s). ${dim("(Pass --force to overwrite everything.)")}`
3482
+ );
3483
+ } else {
3484
+ success(
3485
+ `Scaffolded ${code(slug)} (${kind === "custom" ? "custom Next.js" : "simple module"}) in ${code(dir)}`
3486
+ );
3487
+ }
2523
3488
  let devInstall = null;
2524
3489
  const wantsProvision = options.provision !== false;
2525
3490
  if (wantsProvision) {
@@ -2530,12 +3495,16 @@ async function moduleInitCommand(slugArg, options) {
2530
3495
  return void 0;
2531
3496
  }
2532
3497
  })();
3498
+ const manifestKind = (() => {
3499
+ const mk = manifest?.module_kind;
3500
+ return mk === "ai_builder" || mk === "hand_authored" || mk === "custom" ? mk : void 0;
3501
+ })();
2533
3502
  devInstall = await tryProvisionDevInstall({
2534
3503
  slug,
2535
3504
  name,
2536
3505
  description: options.description,
2537
3506
  workspaceFlag: options.workspace,
2538
- moduleKind: kind,
3507
+ moduleKind: manifestKind ?? kind,
2539
3508
  ...manifest !== void 0 ? { manifest } : {}
2540
3509
  });
2541
3510
  } else {
@@ -2550,7 +3519,8 @@ async function moduleInitCommand(slugArg, options) {
2550
3519
  );
2551
3520
  info(
2552
3521
  `Fix it by re-running provisioning once the cause above is resolved:
2553
- ${code(`dashwise init ${slug} --force --workspace <your-workspace-slug>`)}
3522
+ ${code(`dashwise init ${slug} --workspace <your-workspace-slug>`)}
3523
+ (Existing files are preserved \u2014 a re-run only provisions + fills in missing files.)
2554
3524
  Confirm success: the line ${code("\u2713 Provisioned dev install id=\u2026 API key cached")} must appear,
2555
3525
  and ${code("cat ~/.config/dashwise/auth.json")} must show a non-empty ${code(`modules.${slug}.apiKey`)} starting with ${code("dwk_")}.`
2556
3526
  );
@@ -2576,7 +3546,7 @@ ${devInstall ? `
2576
3546
  async function tryProvisionDevInstall(args) {
2577
3547
  if (!isLoggedIn()) {
2578
3548
  warn(
2579
- `Not logged in \u2014 skipping dev-install provisioning. Run ${code("dashwise login")} then ${code(`dashwise init ${args.slug} --force`)} to retry.`
3549
+ `Not logged in \u2014 skipping dev-install provisioning. Run ${code("dashwise login")} then ${code(`dashwise init ${args.slug}`)} to retry (existing files are preserved).`
2580
3550
  );
2581
3551
  return null;
2582
3552
  }
@@ -2678,7 +3648,7 @@ function surfaceProvisionApiError(err, slug) {
2678
3648
  switch (err.code) {
2679
3649
  case "UNAUTHENTICATED":
2680
3650
  warn(
2681
- `Auth rejected by backend. Your CLI token may be stale \u2014 run ${code("dashwise login")} then ${code(`dashwise init ${slug} --force`)}.`
3651
+ `Auth rejected by backend. Your CLI token may be stale \u2014 run ${code("dashwise login")} then ${code(`dashwise init ${slug}`)} (existing files are preserved).`
2682
3652
  );
2683
3653
  return;
2684
3654
  case "FORBIDDEN":
@@ -2686,11 +3656,30 @@ function surfaceProvisionApiError(err, slug) {
2686
3656
  `Not authorized to create a module in that workspace. Confirm you're a member at the DashWise dashboard.`
2687
3657
  );
2688
3658
  return;
2689
- case "SLUG_ALREADY_EXISTS":
3659
+ case "DEV_INSTALL_ALREADY_EXISTS": {
3660
+ const cached = readModuleEntry(slug);
2690
3661
  warn(
2691
- `A module with slug ${code(slug)} already exists. Pick a different slug, or destroy the existing dev install (LDI-9 coming soon) and re-run.`
3662
+ `Module ${code(slug)} already has a live dev install${cached?.installationId ? ` (cached id=${cached.installationId})` : ""} \u2014 only ONE dev install per module can exist.
3663
+ Run ${code("dashwise module reset-dev-install")} for a clean slate (keeps ALL local files), or ${code("dashwise module destroy-dev-install")} then re-run init.`
2692
3664
  );
2693
3665
  return;
3666
+ }
3667
+ case "SLUG_ALREADY_EXISTS": {
3668
+ const cached = readModuleEntry(slug);
3669
+ if (cached) {
3670
+ warn(
3671
+ `A module with slug ${code(slug)} already exists, and you have its dev install cached (id=${cached.installationId}).
3672
+ For a clean slate run ${code("dashwise module reset-dev-install")} (keeps all local files), or ${code("dashwise module destroy-dev-install")} then re-run init.`
3673
+ );
3674
+ } else {
3675
+ warn(
3676
+ `A module with slug ${code(slug)} already exists.
3677
+ - If it belongs to ANOTHER workspace: module slugs are globally unique \u2014 pick a NEW slug (duplicate the code if needed).
3678
+ - If it's YOUR OWN published module: backends \u2265 2026-07-15 re-provision a dev install from this exact command (${code(`dashwise init ${slug} --workspace <ws>`)} \u2014 the dev install coexists with the production install), so this 409 means the backend predates that. Older-backend fallback (production repair): ${code("dashwise api-keys create local-dev --install <id> --full-access")} + ${code("dashwise origins add http://localhost:3000 --install <id>")} (id via ${code(`dashwise module status ${slug}`)}), then run bare ${code("next dev")} \u2014 and CLEAN UP after (revoke the key, remove the origin).`
3679
+ );
3680
+ }
3681
+ return;
3682
+ }
2694
3683
  case "RUNTIME_DATA_API_DISABLED":
2695
3684
  warn(
2696
3685
  `Backend has the local-dev API turned off (BUILDER_SESSION_TOKEN_SECRET unset). Contact the operator.`
@@ -2737,7 +3726,7 @@ function loadSchemaCommandContext(opts) {
2737
3726
  const entry = readModuleEntry(slug);
2738
3727
  if (!entry) {
2739
3728
  throw new Error(
2740
- `No dev install cached for module "${slug}". Run \`dashwise init ${slug} --force\` to provision one.`
3729
+ `No dev install cached for module "${slug}". Run \`dashwise init ${slug}\` to provision one (existing files are preserved).`
2741
3730
  );
2742
3731
  }
2743
3732
  const token = resolveSchemaAuthToken(entry, slug);
@@ -2758,7 +3747,7 @@ function loadSchemaCommandContext(opts) {
2758
3747
  function resolveSchemaAuthToken(entry, slug) {
2759
3748
  if (entry.apiKey) return entry.apiKey;
2760
3749
  throw new Error(
2761
- `The cached credential for "${slug}" is a legacy runtime token, which the schema endpoints no longer accept. Run \`dashwise dev\` once (it mints a dwk_ API key and updates your config), or \`dashwise init ${slug} --force\` to re-provision.`
3750
+ `The cached credential for "${slug}" is a legacy runtime token, which the schema endpoints no longer accept. Run \`dashwise dev\` once (it mints a dwk_ API key and updates your config), or \`dashwise init ${slug}\` to re-provision (existing files are preserved).`
2762
3751
  );
2763
3752
  }
2764
3753
  var init_schema_command_context = __esm({
@@ -2767,1187 +3756,1013 @@ var init_schema_command_context = __esm({
2767
3756
  init_manifest_io();
2768
3757
  }
2769
3758
  });
2770
-
2771
- // src/commands/module/destroy-dev-install.ts
2772
- var destroy_dev_install_exports = {};
2773
- __export(destroy_dev_install_exports, {
2774
- destroyDevInstallCommand: () => destroyDevInstallCommand
2775
- });
2776
- async function destroyDevInstallCommand(options) {
2777
- let slug = options.module;
2778
- if (!slug) {
2779
- const projectRoot = resolve(options.dir ?? process.cwd());
2780
- const manifest = (() => {
2781
- try {
2782
- return readManifest(projectRoot);
2783
- } catch {
2784
- return null;
2785
- }
2786
- })();
2787
- slug = manifest?.module?.slug;
2788
- if (!slug) {
2789
- fail(
2790
- `No module slug \u2014 pass ${code("--module <slug>")} or run from a directory with a valid module.json.`
2791
- );
2792
- return 1;
2793
- }
2794
- }
2795
- const cfg = readConfig();
2796
- if (!cfg) {
2797
- fail("Not logged in. Run `dashwise login` first.");
2798
- return 1;
2799
- }
2800
- const entry = readModuleEntry(slug);
2801
- if (!entry) {
2802
- warn(
2803
- `No cached dev install for ${code(slug)} in auth.json. Nothing to do.`
2804
- );
2805
- return 0;
2806
- }
2807
- if (!options.yes && process.stdout.isTTY) {
2808
- const choice = await confirm({
2809
- message: `Destroy dev install ${code(`${slug} (id=${entry.installationId})`)}? Tables and rows will be soft-deleted (recoverable for 30 days via dashboard trash).`
2810
- });
2811
- if (isCancel(choice) || !choice) {
2812
- info("Aborted.");
2813
- return 130;
2814
- }
2815
- }
2816
- let backendCleaned = false;
2817
- try {
2818
- const response = await destroyDevInstallation(entry.installationId, {
2819
- baseUrl: resolveApiUrl(),
2820
- auth: { token: cfg.token }
2821
- });
2822
- backendCleaned = true;
2823
- success(
2824
- `Destroyed dev install ${code(`${slug} (id=${response.installation_id})`)}${response.module_also_dropped ? " \u2014 module also dropped (no other installs, unpublished)." : " (module kept \u2014 other installs reference it or it has a published version)."}`
2825
- );
2826
- } catch (err) {
2827
- if (err instanceof ApiError && err.code === "INSTALLATION_NOT_FOUND") {
2828
- warn(
2829
- `Install ${entry.installationId} not found on the backend \u2014 already destroyed elsewhere. Cleaning up local cache.`
2830
- );
2831
- backendCleaned = true;
2832
- } else {
2833
- return surfaceDestroyError(err);
2834
- }
2835
- }
2836
- if (backendCleaned) {
2837
- try {
2838
- removeModuleEntry(slug);
2839
- info(`Removed ${code(slug)} entry from auth.json.`);
2840
- } catch (err) {
2841
- warn(
2842
- `Backend cleanup succeeded but failed to update auth.json: ${err.message}. Edit ${code("~/.config/dashwise/auth.json")} manually to remove the ${code(`modules["${slug}"]`)} entry.`
2843
- );
2844
- }
2845
- }
2846
- return 0;
2847
- }
2848
- function surfaceDestroyError(err) {
2849
- if (err instanceof ApiError) {
2850
- switch (err.code) {
2851
- case "INSTALLATION_KIND_NOT_LOCAL":
2852
- fail(
2853
- `Refusing to destroy: the install isn't kind=local (it may have been promoted to production). Use the dashboard's uninstall flow instead.`
2854
- );
2855
- break;
2856
- case "NOT_WORKSPACE_MEMBER":
2857
- fail(
2858
- `You're not a member of the install's workspace. Contact a workspace admin.`
2859
- );
2860
- break;
2861
- case "UNAUTHENTICATED":
2862
- fail(
2863
- `Auth rejected by backend \u2014 your CLI bearer may be stale. Re-run ${code("dashwise login")} and try again.`
2864
- );
2865
- break;
2866
- default:
2867
- fail(`Backend rejected: ${err.code} ${err.status} \u2014 ${err.message}`);
2868
- }
2869
- return 1;
2870
- }
2871
- fail(`Unexpected error: ${err.message}`);
2872
- return 1;
2873
- }
2874
- var init_destroy_dev_install = __esm({
2875
- "src/commands/module/destroy-dev-install.ts"() {
2876
- init_api_client();
2877
- init_config();
2878
- init_manifest_io();
2879
- init_output();
2880
- }
2881
- });
2882
-
2883
- // src/commands/module/reset-dev-install.ts
2884
- var reset_dev_install_exports = {};
2885
- __export(reset_dev_install_exports, {
2886
- resetDevInstallCommand: () => resetDevInstallCommand
2887
- });
2888
- async function resetDevInstallCommand(options) {
2889
- const projectRoot = resolve(options.dir ?? process.cwd());
2890
- const manifest = (() => {
2891
- try {
2892
- return readManifest(projectRoot);
2893
- } catch {
2894
- return null;
2895
- }
2896
- })();
2897
- const slug = options.module ?? manifest?.module?.slug;
2898
- if (!slug) {
2899
- fail(
2900
- `No module slug \u2014 pass ${code("--module <slug>")} or run from a directory with a valid module.json.`
2901
- );
2902
- return 1;
2903
- }
2904
- const name = manifest?.module?.name ?? slug;
2905
- info(`Resetting dev install for ${code(slug)} \u2014 phase 1/2: destroy\u2026`);
2906
- const destroyExit = await destroyDevInstallCommand({
2907
- ...options.dir !== void 0 ? { dir: options.dir } : {},
2908
- module: slug,
2909
- ...options.yes !== void 0 ? { yes: options.yes } : {}
2910
- });
2911
- if (destroyExit !== 0) {
2912
- fail(
2913
- `Destroy phase failed (exit ${destroyExit}); aborting reset. Fix the underlying error and re-run.`
2914
- );
2915
- return destroyExit;
2916
- }
2917
- info(`Reset phase 2/2: re-provisioning\u2026`);
2918
- return moduleInitCommand(slug, {
2919
- name,
2920
- ...options.dir !== void 0 ? { dir: options.dir } : { dir: projectRoot },
2921
- // `--force` so init doesn't refuse to overwrite the existing
2922
- // module.json (we're keeping the local scaffold; only the
2923
- // backend install is being recreated).
2924
- force: true,
2925
- // Keep the current scaffold layout — don't try to detect or
2926
- // change `simple` vs `custom`. The user can re-init manually
2927
- // with `dashwise init` if they want to swap scaffolds.
2928
- provision: true,
2929
- ...options.workspace !== void 0 ? { workspace: options.workspace } : {}
2930
- });
2931
- }
2932
- var init_reset_dev_install = __esm({
2933
- "src/commands/module/reset-dev-install.ts"() {
2934
- init_destroy_dev_install();
2935
- init_init();
2936
- init_manifest_io();
2937
- init_output();
2938
- }
2939
- });
2940
-
2941
- // src/lib/field-spec.ts
2942
- function parseFieldSpec(spec) {
2943
- const parts = spec.split(":");
2944
- if (parts.length < 2) {
2945
- throw new FieldSpecError(
2946
- `Field spec ${quote(spec)} is malformed \u2014 expected ${quote("<slug>:<type>[:flags]")}.`
2947
- );
2948
- }
2949
- const [slug, type, ...flagParts] = parts;
2950
- if (!SLUG_REGEX.test(slug)) {
2951
- throw new FieldSpecError(
2952
- `Field slug ${quote(slug)} must match ${quote(SLUG_REGEX.source)} (snake_case, 2+ chars).`
2953
- );
2954
- }
2955
- if (!FIELD_TYPES.includes(type)) {
2956
- throw new FieldSpecError(
2957
- `Field type ${quote(type)} is not a known type. Allowed: ${FIELD_TYPES.join(", ")}.`
2958
- );
2959
- }
2960
- const result = { slug, type };
2961
- if (flagParts.length > 0) {
2962
- const flags = flagParts.flatMap((segment) => segment.split(",")).map((f) => f.trim()).filter((f) => f.length > 0);
2963
- for (const flag of flags) {
2964
- if (!KNOWN_FLAGS.has(flag)) {
2965
- throw new FieldSpecError(
2966
- `Unknown flag ${quote(flag)} in field spec ${quote(spec)}. Known flags: ${[...KNOWN_FLAGS].join(", ")}. For options/target/default, use ${quote("--field-json")} instead.`
2967
- );
2968
- }
2969
- if (flag === "required") result.required = true;
2970
- }
2971
- }
2972
- if ((type === "single_select" || type === "multi_select") && result.options === void 0) {
2973
- throw new FieldSpecError(
2974
- `Type ${quote(type)} requires an ${quote("options")} list. Use ${quote("--field-json")} for this field \u2014 e.g. ${quote(`'{"slug":"${slug}","type":"${type}","options":["a","b"]}'`)}.`
2975
- );
2976
- }
2977
- if (type === "link_row" && result.target === void 0) {
2978
- throw new FieldSpecError(
2979
- `Type ${quote("link_row")} requires a ${quote("target")}. Use ${quote("--field-json")}.`
2980
- );
2981
- }
2982
- if (type === "formula" || type === "ai") {
2983
- throw new FieldSpecError(
2984
- `Type ${quote(type)} requires extra config (${quote("formula")} or ${quote("prompt")}). Use ${quote("--field-json")}.`
2985
- );
2986
- }
2987
- return result;
2988
- }
2989
- function parseFieldJson(json) {
2990
- let parsed;
2991
- try {
2992
- parsed = JSON.parse(json);
2993
- } catch (err) {
2994
- throw new FieldSpecError(
2995
- `--field-json value is not valid JSON: ${err.message}`
2996
- );
2997
- }
2998
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2999
- throw new FieldSpecError("--field-json must be a JSON object.");
3000
- }
3001
- const o = parsed;
3002
- if (typeof o.slug !== "string" || !SLUG_REGEX.test(o.slug)) {
3003
- throw new FieldSpecError(
3004
- `--field-json missing or invalid ${quote("slug")}. Must match ${quote(SLUG_REGEX.source)}.`
3005
- );
3006
- }
3007
- if (typeof o.type !== "string" || !FIELD_TYPES.includes(o.type)) {
3008
- throw new FieldSpecError(
3009
- `--field-json missing or invalid ${quote("type")}. Allowed: ${FIELD_TYPES.join(", ")}.`
3010
- );
3759
+ function hydrateEnvFromDotenvLocal(projectRoot) {
3760
+ const envFile = join(projectRoot, ".env.local");
3761
+ if (!existsSync(envFile)) return;
3762
+ const raw = readFileSync(envFile, "utf-8");
3763
+ for (const line of raw.split(/\r?\n/)) {
3764
+ const trimmed = line.trim();
3765
+ if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
3766
+ const eq = trimmed.indexOf("=");
3767
+ if (eq <= 0) continue;
3768
+ const key = trimmed.slice(0, eq).trim();
3769
+ let value = trimmed.slice(eq + 1).trim();
3770
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
3771
+ value = value.slice(1, -1);
3772
+ }
3773
+ if (process.env[key] === void 0) {
3774
+ process.env[key] = value;
3775
+ }
3011
3776
  }
3012
- return o;
3013
- }
3014
- function quote(s) {
3015
- return `\`${s}\``;
3016
3777
  }
3017
- var FIELD_TYPES, SLUG_REGEX, KNOWN_FLAGS, FieldSpecError;
3018
- var init_field_spec = __esm({
3019
- "src/lib/field-spec.ts"() {
3020
- FIELD_TYPES = [
3021
- "text",
3022
- "long_text",
3023
- "number",
3024
- "boolean",
3025
- "date",
3026
- "datetime",
3027
- "single_select",
3028
- "multi_select",
3029
- "link_row",
3030
- "file",
3031
- "multi_file",
3032
- "email",
3033
- "url",
3034
- "phone",
3035
- "formula",
3036
- "ai"
3037
- ];
3038
- SLUG_REGEX = /^[a-z][a-z0-9_]*[a-z0-9]$/;
3039
- KNOWN_FLAGS = /* @__PURE__ */ new Set(["required", "unique"]);
3040
- FieldSpecError = class extends Error {
3041
- constructor(message) {
3042
- super(message);
3043
- this.name = "FieldSpecError";
3044
- }
3045
- };
3778
+ var init_dotenv_local = __esm({
3779
+ "src/lib/dotenv-local.ts"() {
3046
3780
  }
3047
3781
  });
3048
3782
 
3049
- // src/commands/table/create.ts
3050
- var create_exports = {};
3051
- __export(create_exports, {
3052
- surfaceFailure: () => surfaceFailure,
3053
- syncLocalManifest: () => syncLocalManifest,
3054
- tableCreateCommand: () => tableCreateCommand
3783
+ // src/commands/run.ts
3784
+ var run_exports = {};
3785
+ __export(run_exports, {
3786
+ runCommand: () => runCommand
3055
3787
  });
3056
- async function tableCreateCommand(slug, options) {
3057
- if (!slug) {
3058
- fail("Usage: `dashwise table create <slug> --field <slug>:<type>[:required] [...]`");
3788
+ async function runCommand(cmdArgs, options) {
3789
+ if (!cmdArgs || cmdArgs.length === 0) {
3790
+ fail(
3791
+ `Usage: ${code("dashwise run -- <command\u2026>")} (e.g. ${code("dashwise run -- node seed.mjs")})`
3792
+ );
3059
3793
  return 1;
3060
3794
  }
3061
- const fields = [];
3795
+ const projectRoot = resolve(options.dir ?? process.cwd());
3796
+ let manifestSlug;
3062
3797
  try {
3063
- for (const spec of options.field ?? []) {
3064
- fields.push(parseFieldSpec(spec));
3065
- }
3066
- for (const json of options.fieldJson ?? []) {
3067
- fields.push(parseFieldJson(json));
3068
- }
3798
+ manifestSlug = readManifestOrThrow(projectRoot).module?.slug;
3069
3799
  } catch (err) {
3070
- if (err instanceof FieldSpecError) {
3071
- fail(err.message);
3072
- return 1;
3800
+ fail(err.message);
3801
+ return 1;
3802
+ }
3803
+ if (!manifestSlug) {
3804
+ fail(`module.json has no ${code("module.slug")} \u2014 the manifest is malformed.`);
3805
+ return 1;
3806
+ }
3807
+ hydrateEnvFromDotenvLocal(projectRoot);
3808
+ const cfg = (() => {
3809
+ try {
3810
+ return readConfig();
3811
+ } catch {
3812
+ return null;
3073
3813
  }
3074
- throw err;
3814
+ })();
3815
+ const apiUrl = process.env.DASHWISE_API_URL ?? cfg?.apiUrl ?? "http://localhost:3000";
3816
+ let apiKey = process.env.DASHWISE_API_KEY;
3817
+ if (!apiKey) {
3818
+ let entry = readModuleEntry(manifestSlug);
3819
+ if (cfg) {
3820
+ const healed = await resolveModuleApiKey(
3821
+ manifestSlug,
3822
+ entry,
3823
+ apiUrl,
3824
+ cfg.token
3825
+ );
3826
+ if (healed) entry = healed;
3827
+ }
3828
+ apiKey = entry?.apiKey;
3075
3829
  }
3076
- if (fields.length === 0) {
3830
+ if (!apiKey) {
3077
3831
  fail(
3078
- `At least one field is required. Use ${code("--field <slug>:<type>")} (repeatable) or ${code("--field-json '{...}'")} for fields with options/target/default.`
3832
+ `No ${code("dwk_")} API key for module ${code(manifestSlug)} \u2014 the command would run with NO token.`
3833
+ );
3834
+ info(
3835
+ `Provision a dev install first (${code(`dashwise init ${manifestSlug} --workspace <your-workspace-slug>`)}), or set ${code("DASHWISE_API_KEY")} explicitly.`
3079
3836
  );
3080
3837
  return 1;
3081
3838
  }
3082
- const seen = /* @__PURE__ */ new Set();
3083
- for (const f of fields) {
3084
- if (seen.has(f.slug)) {
3085
- fail(`Duplicate field slug ${code(f.slug)} in --field flags.`);
3839
+ const [cmd, ...rest] = cmdArgs;
3840
+ info(
3841
+ `${dim("run")} ${cmd} ${rest.join(" ")} ${dim(`(DASHWISE_API_URL=${apiUrl}, DASHWISE_API_KEY injected)`)}`
3842
+ );
3843
+ const child = spawn(cmd, rest, {
3844
+ cwd: projectRoot,
3845
+ stdio: "inherit",
3846
+ env: {
3847
+ ...process.env,
3848
+ DASHWISE_API_URL: apiUrl,
3849
+ DASHWISE_API_KEY: apiKey
3850
+ },
3851
+ // Windows needs shell:true for .cmd shims (npm / npx).
3852
+ shell: process.platform === "win32"
3853
+ });
3854
+ return new Promise((resolveExit) => {
3855
+ child.on("error", (err) => {
3856
+ fail(`Could not spawn ${code(cmd)}: ${err.message}`);
3857
+ resolveExit(1);
3858
+ });
3859
+ child.on("exit", (exitCode, signal) => {
3860
+ resolveExit(exitCode ?? (signal !== null ? 1 : 0));
3861
+ });
3862
+ });
3863
+ }
3864
+ var init_run = __esm({
3865
+ "src/commands/run.ts"() {
3866
+ init_config();
3867
+ init_dotenv_local();
3868
+ init_manifest_io();
3869
+ init_output();
3870
+ init_dev();
3871
+ }
3872
+ });
3873
+
3874
+ // src/commands/module/destroy-dev-install.ts
3875
+ var destroy_dev_install_exports = {};
3876
+ __export(destroy_dev_install_exports, {
3877
+ destroyDevInstallCommand: () => destroyDevInstallCommand
3878
+ });
3879
+ async function destroyDevInstallCommand(options) {
3880
+ let slug = options.module;
3881
+ if (!slug) {
3882
+ const projectRoot = resolve(options.dir ?? process.cwd());
3883
+ const manifest = (() => {
3884
+ try {
3885
+ return readManifest(projectRoot);
3886
+ } catch {
3887
+ return null;
3888
+ }
3889
+ })();
3890
+ slug = manifest?.module?.slug;
3891
+ if (!slug) {
3892
+ fail(
3893
+ `No module slug \u2014 pass ${code("--module <slug>")} or run from a directory with a valid module.json.`
3894
+ );
3086
3895
  return 1;
3087
3896
  }
3088
- seen.add(f.slug);
3089
3897
  }
3090
- let ctx;
3091
- try {
3092
- ctx = loadSchemaCommandContext({
3093
- ...options.dir !== void 0 ? { dir: options.dir } : {}
3094
- });
3095
- } catch (err) {
3096
- fail(err.message);
3898
+ const cfg = readConfig();
3899
+ if (!cfg) {
3900
+ fail("Not logged in. Run `dashwise login` first.");
3097
3901
  return 1;
3098
3902
  }
3099
- info(`Creating table ${code(slug)} in installation ${ctx.entry.installationId}\u2026`);
3100
- try {
3101
- const result = await createTable(
3102
- { slug, fields },
3103
- ctx.requestOptions
3104
- );
3105
- success(
3106
- `Created table ${code(slug)} (physical_table_id=${result.physical_table_id}, manifest_version=${result.manifest_version})`
3903
+ const entry = readModuleEntry(slug);
3904
+ if (!entry) {
3905
+ warn(
3906
+ `No cached dev install for ${code(slug)} in auth.json. Nothing to do.`
3107
3907
  );
3108
- } catch (err) {
3109
- return surfaceFailure(err);
3908
+ return 0;
3110
3909
  }
3111
- return syncLocalManifest(ctx);
3112
- }
3113
- async function syncLocalManifest(ctx) {
3910
+ if (!options.yes && process.stdout.isTTY) {
3911
+ const choice = await confirm({
3912
+ message: `Destroy dev install ${code(`${slug} (id=${entry.installationId})`)}? Tables and rows will be soft-deleted (recoverable for 30 days via dashboard trash).`
3913
+ });
3914
+ if (isCancel(choice) || !choice) {
3915
+ info("Aborted.");
3916
+ return 130;
3917
+ }
3918
+ }
3919
+ let backendCleaned = false;
3114
3920
  try {
3115
- const { manifest } = await getInstallationSchema(ctx.requestOptions);
3116
- writeManifest(manifest, ctx.projectRoot);
3117
- return 0;
3921
+ const response = await destroyDevInstallation(entry.installationId, {
3922
+ baseUrl: resolveApiUrl(),
3923
+ auth: { token: cfg.token }
3924
+ });
3925
+ backendCleaned = true;
3926
+ success(
3927
+ `Destroyed dev install ${code(`${slug} (id=${response.installation_id})`)}${response.module_also_dropped ? " \u2014 module also dropped (no other installs, unpublished)." : " (module kept \u2014 other installs reference it or it has a published version)."}`
3928
+ );
3118
3929
  } catch (err) {
3119
- if (err instanceof ApiError) {
3930
+ if (err instanceof ApiError && err.code === "INSTALLATION_NOT_FOUND") {
3120
3931
  warn(
3121
- `Schema sync to local module.json failed (${err.code} ${err.status}): ${err.message}. The mutation succeeded server-side; re-run your next command to retry the local sync.`
3932
+ `Install ${entry.installationId} not found on the backend \u2014 already destroyed elsewhere. Cleaning up local cache.`
3122
3933
  );
3934
+ backendCleaned = true;
3123
3935
  } else {
3936
+ return surfaceDestroyError(err);
3937
+ }
3938
+ }
3939
+ if (backendCleaned) {
3940
+ try {
3941
+ removeModuleEntry(slug);
3942
+ info(`Removed ${code(slug)} entry from auth.json.`);
3943
+ } catch (err) {
3124
3944
  warn(
3125
- `Schema sync to local module.json failed: ${err.message}. The mutation succeeded server-side.`
3945
+ `Backend cleanup succeeded but failed to update auth.json: ${err.message}. Edit ${code("~/.config/dashwise/auth.json")} manually to remove the ${code(`modules["${slug}"]`)} entry.`
3126
3946
  );
3127
3947
  }
3128
- return 0;
3129
3948
  }
3949
+ return 0;
3130
3950
  }
3131
- function surfaceFailure(err) {
3951
+ function surfaceDestroyError(err) {
3132
3952
  if (err instanceof ApiError) {
3133
3953
  switch (err.code) {
3134
- case "TABLE_SLUG_EXISTS":
3135
- fail(`A table with this slug already exists in the manifest.`);
3136
- break;
3137
- case "FIELD_SLUG_EXISTS":
3138
- fail(`A field with this slug already exists on the table.`);
3139
- break;
3140
- case "TABLE_NOT_FOUND":
3141
- fail(`Table not found in the manifest.`);
3142
- break;
3143
- case "FIELD_NOT_FOUND":
3144
- fail(`Field not found on the table.`);
3145
- break;
3146
- case "UNAUTHENTICATED":
3954
+ case "INSTALLATION_KIND_NOT_LOCAL":
3147
3955
  fail(
3148
- `API key rejected by backend. The cached key may be expired or revoked \u2014 re-run \`dashwise init <slug> --force\` to mint a fresh one.`
3956
+ `Refusing to destroy: the install isn't kind=local (it may have been promoted to production). Use the dashboard's uninstall flow instead.`
3149
3957
  );
3150
3958
  break;
3151
- case "SCHEMA_LOCKED":
3959
+ case "NOT_WORKSPACE_MEMBER":
3152
3960
  fail(
3153
- `Schema mutations require a local-dev install (kind=local). The cached API key belongs to a non-local install \u2014 schema is locked. Run \`dashwise init <slug> --force\` for a local dev install.`
3961
+ `You're not a member of the install's workspace. Contact a workspace admin.`
3154
3962
  );
3155
3963
  break;
3156
- case "INSTALL_MISMATCH":
3964
+ case "UNAUTHENTICATED":
3157
3965
  fail(
3158
- `API key is bound to a different installation than the URL. The cached install id may be stale \u2014 re-run \`dashwise init <slug> --force\`.`
3966
+ `Auth rejected by backend \u2014 your CLI bearer may be stale. Re-run ${code("dashwise login")} and try again.`
3159
3967
  );
3160
3968
  break;
3161
3969
  default:
3162
- fail(`Backend rejected: ${err.code} ${err.status} \u2014 ${err.message}`);
3163
- }
3164
- return 1;
3165
- }
3166
- fail(`Unexpected error: ${err.message}`);
3167
- return 1;
3168
- }
3169
- var init_create = __esm({
3170
- "src/commands/table/create.ts"() {
3171
- init_manifest_io();
3172
- init_api_client();
3173
- init_schema_command_context();
3174
- init_field_spec();
3175
- init_output();
3176
- }
3177
- });
3178
-
3179
- // src/commands/table/delete.ts
3180
- var delete_exports = {};
3181
- __export(delete_exports, {
3182
- tableDeleteCommand: () => tableDeleteCommand
3183
- });
3184
- async function tableDeleteCommand(slug, options) {
3185
- if (!slug) {
3186
- fail("Usage: `dashwise table delete <slug>`");
3187
- return 1;
3188
- }
3189
- let ctx;
3190
- try {
3191
- ctx = loadSchemaCommandContext({
3192
- ...options.dir !== void 0 ? { dir: options.dir } : {}
3193
- });
3194
- } catch (err) {
3195
- fail(err.message);
3196
- return 1;
3197
- }
3198
- info(`Dropping table ${code(slug)} from installation ${ctx.entry.installationId}\u2026`);
3199
- try {
3200
- const result = await dropTable(slug, ctx.requestOptions);
3201
- success(
3202
- `Dropped table ${code(slug)} (manifest_version=${result.manifest_version}). Physical table soft-deleted; recoverable for 30 days via the dashboard's trash.`
3203
- );
3204
- } catch (err) {
3205
- return surfaceFailure(err);
3970
+ fail(`Backend rejected: ${err.code} ${err.status} \u2014 ${err.message}`);
3971
+ }
3972
+ return 1;
3206
3973
  }
3207
- return syncLocalManifest(ctx);
3974
+ fail(`Unexpected error: ${err.message}`);
3975
+ return 1;
3208
3976
  }
3209
- var init_delete = __esm({
3210
- "src/commands/table/delete.ts"() {
3977
+ var init_destroy_dev_install = __esm({
3978
+ "src/commands/module/destroy-dev-install.ts"() {
3211
3979
  init_api_client();
3212
- init_schema_command_context();
3980
+ init_config();
3981
+ init_manifest_io();
3213
3982
  init_output();
3214
- init_create();
3215
3983
  }
3216
3984
  });
3217
3985
 
3218
- // src/commands/field/add.ts
3219
- var add_exports = {};
3220
- __export(add_exports, {
3221
- fieldAddCommand: () => fieldAddCommand
3986
+ // src/commands/module/reset-dev-install.ts
3987
+ var reset_dev_install_exports = {};
3988
+ __export(reset_dev_install_exports, {
3989
+ resetDevInstallCommand: () => resetDevInstallCommand
3222
3990
  });
3223
- async function fieldAddCommand(tableSlug, fieldSpec, options) {
3224
- if (!tableSlug) {
3991
+ async function resetDevInstallCommand(options) {
3992
+ const projectRoot = resolve(options.dir ?? process.cwd());
3993
+ const manifest = (() => {
3994
+ try {
3995
+ return readManifest(projectRoot);
3996
+ } catch {
3997
+ return null;
3998
+ }
3999
+ })();
4000
+ const slug = options.module ?? manifest?.module?.slug;
4001
+ if (!slug) {
3225
4002
  fail(
3226
- "Usage: `dashwise field add <table-slug> <field-slug>:<type>[:required]` or `dashwise field add <table-slug> --field-json '{...}'`"
4003
+ `No module slug \u2014 pass ${code("--module <slug>")} or run from a directory with a valid module.json.`
3227
4004
  );
3228
4005
  return 1;
3229
4006
  }
3230
- const hasPositional = fieldSpec !== void 0;
3231
- const hasJson = options.fieldJson !== void 0;
3232
- if (!hasPositional && !hasJson) {
4007
+ const name = manifest?.module?.name ?? slug;
4008
+ info(`Resetting dev install for ${code(slug)} \u2014 phase 1/2: destroy\u2026`);
4009
+ const destroyExit = await destroyDevInstallCommand({
4010
+ ...options.dir !== void 0 ? { dir: options.dir } : {},
4011
+ module: slug,
4012
+ ...options.yes !== void 0 ? { yes: options.yes } : {}
4013
+ });
4014
+ if (destroyExit !== 0) {
3233
4015
  fail(
3234
- `Pass either a field spec (e.g. ${code("title:text:required")}) or ${code("--field-json")} with the field's JSON definition.`
4016
+ `Destroy phase failed (exit ${destroyExit}); aborting reset. Fix the underlying error and re-run.`
3235
4017
  );
4018
+ return destroyExit;
4019
+ }
4020
+ info(`Reset phase 2/2: re-provisioning\u2026`);
4021
+ return moduleInitCommand(slug, {
4022
+ name,
4023
+ ...options.dir !== void 0 ? { dir: options.dir } : { dir: projectRoot },
4024
+ // Provision-only: write NO files — every local file (module.json
4025
+ // included) stays exactly as-is; only the backend dev install +
4026
+ // the auth.json credentials entry are recreated. This is what the
4027
+ // command's "keeps local module.json intact" contract always
4028
+ // promised (pre-fix `force: true` re-scaffolded over user code).
4029
+ provisionOnly: true,
4030
+ provision: true,
4031
+ ...options.workspace !== void 0 ? { workspace: options.workspace } : {}
4032
+ });
4033
+ }
4034
+ var init_reset_dev_install = __esm({
4035
+ "src/commands/module/reset-dev-install.ts"() {
4036
+ init_destroy_dev_install();
4037
+ init_init();
4038
+ init_manifest_io();
4039
+ init_output();
4040
+ }
4041
+ });
4042
+
4043
+ // src/commands/schema/push.ts
4044
+ var push_exports = {};
4045
+ __export(push_exports, {
4046
+ schemaPushCommand: () => schemaPushCommand
4047
+ });
4048
+ async function schemaPushCommand(options) {
4049
+ const projectRoot = resolve(options.dir ?? process.cwd());
4050
+ let manifest;
4051
+ try {
4052
+ manifest = readManifestOrThrow(projectRoot);
4053
+ } catch (err) {
4054
+ fail(err.message);
3236
4055
  return 1;
3237
4056
  }
3238
- if (hasPositional && hasJson) {
3239
- fail(
3240
- `Pass only ONE of the field spec or ${code("--field-json")} \u2014 they're mutually exclusive.`
3241
- );
4057
+ const slug = manifest.module?.slug;
4058
+ if (!slug) {
4059
+ fail(`module.json has no ${code("module.slug")} \u2014 the manifest is malformed.`);
3242
4060
  return 1;
3243
4061
  }
3244
- let field;
4062
+ const cfg = readConfig();
4063
+ if (!cfg) {
4064
+ fail(`Not logged in. Run ${code("dashwise login")} first.`);
4065
+ return 1;
4066
+ }
4067
+ const auth = { baseUrl: resolveApiUrl(), auth: { token: cfg.token } };
4068
+ let moduleId;
3245
4069
  try {
3246
- field = hasJson ? parseFieldJson(options.fieldJson) : parseFieldSpec(fieldSpec);
4070
+ moduleId = (await getModuleBySlug(slug, auth)).id;
3247
4071
  } catch (err) {
3248
- if (err instanceof FieldSpecError) {
3249
- fail(err.message);
4072
+ if (err instanceof ApiError && err.status === 404) {
4073
+ fail(
4074
+ `Module ${code(slug)} isn't registered on the backend yet. Run ${code("dashwise init")} (or ${code("dashwise module register")}) first.`
4075
+ );
3250
4076
  return 1;
3251
4077
  }
3252
- throw err;
3253
- }
3254
- let ctx;
3255
- try {
3256
- ctx = loadSchemaCommandContext({
3257
- ...options.dir !== void 0 ? { dir: options.dir } : {}
3258
- });
3259
- } catch (err) {
3260
- fail(err.message);
4078
+ fail(`Could not resolve module ${code(slug)}: ${err.message}`);
3261
4079
  return 1;
3262
4080
  }
3263
- info(
3264
- `Adding field ${code(`${tableSlug}.${field.slug}`)} (${field.type}) to installation ${ctx.entry.installationId}\u2026`
3265
- );
3266
- try {
3267
- const result = await addField(
3268
- { table_slug: tableSlug, field },
3269
- ctx.requestOptions
4081
+ const entry = readModuleEntry(slug);
4082
+ const dwkAuth = entry?.apiKey ? { baseUrl: resolveApiUrl(), auth: { token: entry.apiKey } } : null;
4083
+ let remote = null;
4084
+ let previewUnavailableReason = null;
4085
+ if (!dwkAuth) {
4086
+ previewUnavailableReason = `no dev-install ${code("dwk_")} key is cached for ${code(slug)} in auth.json`;
4087
+ } else {
4088
+ try {
4089
+ remote = (await getInstallationSchema(dwkAuth)).manifest;
4090
+ } catch (err) {
4091
+ const detail = err instanceof ApiError ? `${err.code} ${err.status}: ${err.message}` : err.message;
4092
+ previewUnavailableReason = `reading the dev-install manifest failed (${detail}) \u2014 the cached key may be expired or revoked`;
4093
+ }
4094
+ }
4095
+ if (remote) {
4096
+ const delta = computeDestructiveDelta(
4097
+ manifest,
4098
+ remote
3270
4099
  );
3271
- success(
3272
- `Added field ${code(`${tableSlug}.${field.slug}`)} (manifest_version=${result.manifest_version})`
4100
+ if (delta.tablesRemoved.length > 0 || delta.fieldsRemoved.length > 0) {
4101
+ warn(`This push REMOVES schema from the dev install (live DDL):`);
4102
+ for (const t of delta.tablesRemoved) {
4103
+ out(` - table ${code(t)} ${dim("(soft-deleted; rows recoverable for 30 days via trash)")}`);
4104
+ }
4105
+ for (const f of delta.fieldsRemoved) {
4106
+ out(` - field ${code(f)} ${dim("(column dropped)")}`);
4107
+ }
4108
+ if (!options.yes) {
4109
+ if (!process.stdout.isTTY) {
4110
+ fail(
4111
+ `Refusing the destructive push without confirmation. Re-run with ${code("--yes")} to apply the removals.`
4112
+ );
4113
+ return 1;
4114
+ }
4115
+ const answer = await select({
4116
+ message: `Apply these removals to the dev install?`,
4117
+ options: [
4118
+ { value: "apply", label: "Apply \u2014 remove the tables/fields listed above" },
4119
+ { value: "cancel", label: "Cancel \u2014 nothing is changed" }
4120
+ ],
4121
+ initialValue: "cancel"
4122
+ });
4123
+ if (isCancel(answer) || answer === "cancel") {
4124
+ fail("Aborted. Nothing was changed.");
4125
+ return 130;
4126
+ }
4127
+ }
4128
+ }
4129
+ } else {
4130
+ warn(
4131
+ `Cannot preview what this push would change: ${previewUnavailableReason}.
4132
+ Without the preview there is no way to know whether the push REMOVES tables/fields (live DDL on the dev install).`
4133
+ );
4134
+ if (!options.yes) {
4135
+ fail(
4136
+ `Refusing to push without the destructive-change preview. Re-run with ${code("--yes")} to push anyway (the backend still validates the apply).`
4137
+ );
4138
+ return 1;
4139
+ }
4140
+ info(
4141
+ dim(
4142
+ `--yes passed \u2014 pushing WITHOUT the destructive-change preview.`
4143
+ )
4144
+ );
4145
+ }
4146
+ info(`Pushing module.json to the dev install of ${code(slug)} (module id=${moduleId})\u2026`);
4147
+ let applied;
4148
+ try {
4149
+ applied = await applyDraftManifest(
4150
+ moduleId,
4151
+ manifest,
4152
+ auth
3273
4153
  );
3274
4154
  } catch (err) {
3275
- return surfaceFailure(err);
4155
+ return surfaceSchemaPushError(err, slug);
3276
4156
  }
3277
- return syncLocalManifest(ctx);
4157
+ printApplySummary(applied);
4158
+ if (dwkAuth) {
4159
+ try {
4160
+ const { manifest: canonical } = await getInstallationSchema(dwkAuth);
4161
+ const localNow = (() => {
4162
+ try {
4163
+ return readManifest(projectRoot);
4164
+ } catch {
4165
+ return null;
4166
+ }
4167
+ })();
4168
+ const { merged, preserved } = mergeRemoteManifest(localNow, canonical);
4169
+ writeManifest(merged, projectRoot);
4170
+ if (preserved.length > 0) {
4171
+ info(
4172
+ dim(
4173
+ `[sync] kept local-only manifest blocks the backend does not echo (${preserved.join(", ")}) \u2014 they were part of this push and stay in module.json.`
4174
+ )
4175
+ );
4176
+ }
4177
+ } catch (err) {
4178
+ warn(
4179
+ `Push succeeded but the local sync-back failed: ${err.message}. Local module.json is what you pushed; the next sync reconciles.`
4180
+ );
4181
+ }
4182
+ }
4183
+ return 0;
3278
4184
  }
3279
- var init_add = __esm({
3280
- "src/commands/field/add.ts"() {
3281
- init_api_client();
3282
- init_schema_command_context();
3283
- init_field_spec();
3284
- init_output();
3285
- init_create();
4185
+ function printApplySummary(applied) {
4186
+ const created = asArray(applied.tables_created);
4187
+ const updated = asArray(applied.tables_updated);
4188
+ const removed = asArray(applied.tables_removed);
4189
+ success(`Manifest applied to the dev install.`);
4190
+ if (created.length === 0 && updated.length === 0 && removed.length === 0) {
4191
+ info(dim("No table changes (metadata-only or already in sync)."));
4192
+ return;
3286
4193
  }
3287
- });
3288
-
3289
- // src/commands/field/remove.ts
3290
- var remove_exports = {};
3291
- __export(remove_exports, {
3292
- fieldRemoveCommand: () => fieldRemoveCommand
3293
- });
3294
- async function fieldRemoveCommand(tableSlug, fieldSlug, options) {
3295
- if (!tableSlug || !fieldSlug) {
3296
- fail("Usage: `dashwise field remove <table-slug> <field-slug>`");
3297
- return 1;
4194
+ for (const t of created) {
4195
+ const slug = strProp(t, "slug") ?? "(table)";
4196
+ const fields = asArray(t.fields_created);
4197
+ out(` + table ${code(slug)}${fields.length > 0 ? dim(` (${fields.length} field${fields.length === 1 ? "" : "s"})`) : ""}`);
3298
4198
  }
3299
- let ctx;
3300
- try {
3301
- ctx = loadSchemaCommandContext({
3302
- ...options.dir !== void 0 ? { dir: options.dir } : {}
3303
- });
3304
- } catch (err) {
3305
- fail(err.message);
3306
- return 1;
4199
+ for (const t of updated) {
4200
+ const slug = strProp(t, "slug") ?? "(table)";
4201
+ const added = asArray(t.fields_added);
4202
+ const dropped = asArray(t.fields_removed);
4203
+ const bits = [];
4204
+ if (added.length > 0) bits.push(`+${added.length} field${added.length === 1 ? "" : "s"}`);
4205
+ if (dropped.length > 0) bits.push(`-${dropped.length} field${dropped.length === 1 ? "" : "s"}`);
4206
+ out(` ~ table ${code(slug)}${bits.length > 0 ? dim(` (${bits.join(", ")})`) : ""}`);
3307
4207
  }
3308
- info(
3309
- `Removing field ${code(`${tableSlug}.${fieldSlug}`)} from installation ${ctx.entry.installationId}\u2026`
3310
- );
3311
- try {
3312
- const result = await removeField(
3313
- tableSlug,
3314
- fieldSlug,
3315
- ctx.requestOptions
3316
- );
3317
- success(
3318
- `Removed field ${code(`${tableSlug}.${fieldSlug}`)} (manifest_version=${result.manifest_version})`
3319
- );
3320
- } catch (err) {
3321
- return surfaceFailure(err);
4208
+ for (const t of removed) {
4209
+ out(` - table ${code(typeof t === "string" ? t : JSON.stringify(t))}`);
3322
4210
  }
3323
- return syncLocalManifest(ctx);
3324
4211
  }
3325
- var init_remove = __esm({
3326
- "src/commands/field/remove.ts"() {
4212
+ function asArray(v) {
4213
+ return Array.isArray(v) ? v : [];
4214
+ }
4215
+ function strProp(v, key) {
4216
+ if (v && typeof v === "object" && typeof v[key] === "string") {
4217
+ return v[key];
4218
+ }
4219
+ return void 0;
4220
+ }
4221
+ function surfaceSchemaPushError(err, slug) {
4222
+ if (err instanceof ApiError) {
4223
+ switch (err.code) {
4224
+ case "MANIFEST_INVALID":
4225
+ fail(`Manifest rejected by the backend validator: ${err.message}`);
4226
+ for (const issue of extractIssues2(err.context)) {
4227
+ out(` ${dim("-")} ${issue}`);
4228
+ }
4229
+ return 1;
4230
+ case "DRAFT_INSTALLATION_MISSING":
4231
+ fail(
4232
+ `Module ${code(slug)} has no dev (local/sandbox) install in its owner workspace \u2014 production installs have NO draft plane, so there is nothing to push onto.
4233
+ This happens after ${code("dashwise module install --replace-dev-install")}. Schema changes now ship as published versions: edit module.json, ${code("dashwise module publish --bump <patch|minor|major>")}, then ${code("dashwise module install")} to upgrade.`
4234
+ );
4235
+ return 1;
4236
+ case "UNIQUE_INDEX_BACKFILL_CONFLICT":
4237
+ fail(
4238
+ `Cannot create a UNIQUE index \u2014 existing live rows already violate it: ${err.message}
4239
+ De-duplicate the conflicting rows (only live rows count; trashed rows don't block since indexes are partial over live rows), then re-run ${code("dashwise schema push")}.`
4240
+ );
4241
+ return 1;
4242
+ case "CHECK_CONSTRAINT_BACKFILL_CONFLICT":
4243
+ fail(
4244
+ `Cannot add the CHECK constraint \u2014 existing rows violate its expression: ${err.message}
4245
+ Fix or remove the violating rows, then re-run ${code("dashwise schema push")}.`
4246
+ );
4247
+ return 1;
4248
+ case "UNAUTHENTICATED":
4249
+ fail(
4250
+ `Auth rejected by the backend \u2014 your CLI token may be stale. Re-run ${code("dashwise login")} and try again.`
4251
+ );
4252
+ return 1;
4253
+ case "FORBIDDEN":
4254
+ case "NOT_WORKSPACE_MEMBER":
4255
+ fail(
4256
+ `Not authorized: pushing a draft manifest requires membership of the module's owner workspace.`
4257
+ );
4258
+ return 1;
4259
+ default:
4260
+ if (err.status === 400) {
4261
+ fail(`Manifest rejected: ${err.message}`);
4262
+ for (const issue of extractIssues2(err.context)) {
4263
+ out(` ${dim("-")} ${issue}`);
4264
+ }
4265
+ return 1;
4266
+ }
4267
+ fail(`Schema push failed (${err.code} ${err.status}): ${err.message}`);
4268
+ return 1;
4269
+ }
4270
+ }
4271
+ fail(`Schema push failed: ${err.message}`);
4272
+ return 1;
4273
+ }
4274
+ function extractIssues2(context) {
4275
+ if (!context) return [];
4276
+ const raw = context.issues;
4277
+ if (!Array.isArray(raw)) return [];
4278
+ return raw.map((i) => {
4279
+ if (typeof i === "string") return i;
4280
+ if (i && typeof i === "object") {
4281
+ const o = i;
4282
+ const path = typeof o.path === "string" ? o.path : "";
4283
+ const message = typeof o.message === "string" ? o.message : JSON.stringify(i);
4284
+ return path ? `${path}: ${message}` : message;
4285
+ }
4286
+ return String(i);
4287
+ });
4288
+ }
4289
+ var init_push = __esm({
4290
+ "src/commands/schema/push.ts"() {
3327
4291
  init_api_client();
3328
- init_schema_command_context();
4292
+ init_config();
4293
+ init_manifest_io();
4294
+ init_manifest_merge();
3329
4295
  init_output();
3330
- init_create();
3331
4296
  }
3332
4297
  });
3333
4298
 
3334
- // src/commands/field/set-type.ts
3335
- var set_type_exports = {};
3336
- __export(set_type_exports, {
3337
- fieldSetTypeCommand: () => fieldSetTypeCommand
3338
- });
3339
- async function fieldSetTypeCommand(tableSlug, fieldSlug, newType, options) {
3340
- if (!tableSlug || !fieldSlug || !newType) {
3341
- fail(
3342
- "Usage: `dashwise field set-type <table-slug> <field-slug> <new-type>`"
4299
+ // src/lib/field-spec.ts
4300
+ function parseFieldSpec(spec) {
4301
+ const parts = spec.split(":");
4302
+ if (parts.length < 2) {
4303
+ throw new FieldSpecError(
4304
+ `Field spec ${quote(spec)} is malformed \u2014 expected ${quote("<slug>:<type>[:flags]")}.`
3343
4305
  );
3344
- return 1;
3345
4306
  }
3346
- if (!FIELD_TYPES.includes(newType)) {
3347
- fail(
3348
- `Type ${code(newType)} is not a known type. Allowed: ${FIELD_TYPES.join(", ")}.`
4307
+ const [slug, type, ...flagParts] = parts;
4308
+ if (!SLUG_REGEX.test(slug)) {
4309
+ throw new FieldSpecError(
4310
+ `Field slug ${quote(slug)} must match ${quote(SLUG_REGEX.source)} (snake_case, 2+ chars).`
3349
4311
  );
3350
- return 1;
3351
- }
3352
- let ctx;
3353
- try {
3354
- ctx = loadSchemaCommandContext({
3355
- ...options.dir !== void 0 ? { dir: options.dir } : {}
3356
- });
3357
- } catch (err) {
3358
- fail(err.message);
3359
- return 1;
3360
4312
  }
3361
- info(
3362
- `Setting type of ${code(`${tableSlug}.${fieldSlug}`)} \u2192 ${code(newType)} on installation ${ctx.entry.installationId}\u2026`
3363
- );
3364
- try {
3365
- const result = await updateField(
3366
- tableSlug,
3367
- fieldSlug,
3368
- { type: newType },
3369
- ctx.requestOptions
3370
- );
3371
- success(
3372
- `Updated ${code(`${tableSlug}.${fieldSlug}`)} \u2192 ${code(newType)} (manifest_version=${result.manifest_version})`
4313
+ if (!FIELD_TYPES.includes(type)) {
4314
+ throw new FieldSpecError(
4315
+ `Field type ${quote(type)} is not a known type. Allowed: ${FIELD_TYPES.join(", ")}.`
3373
4316
  );
3374
- } catch (err) {
3375
- return surfaceFailure(err);
3376
4317
  }
3377
- return syncLocalManifest(ctx);
3378
- }
3379
- var init_set_type = __esm({
3380
- "src/commands/field/set-type.ts"() {
3381
- init_api_client();
3382
- init_schema_command_context();
3383
- init_field_spec();
3384
- init_output();
3385
- init_create();
4318
+ const result = { slug, type };
4319
+ if (flagParts.length > 0) {
4320
+ const flags = flagParts.flatMap((segment) => segment.split(",")).map((f) => f.trim()).filter((f) => f.length > 0);
4321
+ for (const flag of flags) {
4322
+ if (flag === "unique") {
4323
+ throw new FieldSpecError(
4324
+ `The ${quote("unique")} flag is not supported on field specs \u2014 uniqueness is a TABLE-level index. Declare it in module.json as ${quote(`owns.tables[].indexes: [{ "fields": ["${slug}"], "unique": true }]`)} and apply it with ${quote("dashwise schema push")}.`
4325
+ );
4326
+ }
4327
+ if (!KNOWN_FLAGS.has(flag)) {
4328
+ throw new FieldSpecError(
4329
+ `Unknown flag ${quote(flag)} in field spec ${quote(spec)}. Known flags: ${[...KNOWN_FLAGS].join(", ")}. For options/target/default, use ${quote("--field-json")} instead.`
4330
+ );
4331
+ }
4332
+ if (flag === "required") result.required = true;
4333
+ }
3386
4334
  }
3387
- });
3388
-
3389
- // src/commands/dev.ts
3390
- init_api_client();
3391
-
3392
- // src/lib/connect-status.ts
3393
- init_api_client();
3394
- init_output();
3395
- async function reportConnectStatus(opts) {
3396
- const [deps, bindings] = await Promise.all([
3397
- safeGet(
3398
- () => getInstallationDependencies(opts.installationId, opts.requestOptions)
3399
- ),
3400
- safeGet(
3401
- () => getInstallationBindings(opts.installationId, opts.requestOptions)
3402
- )
3403
- ]);
3404
- if (deps === null && bindings === null) return;
3405
- const depList = deps ?? [];
3406
- const bindList = bindings ?? [];
3407
- if (depList.length === 0 && bindList.length === 0) return;
3408
- const unboundDeps = depList.filter((d) => d.status === "unbound").length;
3409
- const unboundUses = bindList.filter((b) => b.status === "unbound").length;
3410
- info(
3411
- `Connect status for ${code(opts.moduleSlug)} ${dim(
3412
- `(${depList.length} dep${depList.length === 1 ? "" : "s"}, ${bindList.length} uses slot${bindList.length === 1 ? "" : "s"})`
3413
- )}:`
3414
- );
3415
- for (const d of depList) renderDep(d);
3416
- for (const b of bindList) renderBinding(b);
3417
- const hints = [];
3418
- if (unboundDeps > 0) {
3419
- hints.push(
3420
- `${code("dashwise deps bind <provider-slug>")} once the provider is installed`
4335
+ if ((type === "single_select" || type === "multi_select") && result.options === void 0) {
4336
+ throw new FieldSpecError(
4337
+ `Type ${quote(type)} requires an ${quote("options")} list. Use ${quote("--field-json")} for this field \u2014 e.g. ${quote(`'{"slug":"${slug}","type":"${type}","options":["a","b"]}'`)}.`
3421
4338
  );
3422
4339
  }
3423
- if (unboundUses > 0) {
3424
- hints.push(`${code("dashwise bind <uses-slug> --table <name>")} to connect a table`);
3425
- }
3426
- if (hints.length > 0) {
3427
- info(`Connect parked items with ${hints.join("; ")}.`);
3428
- }
3429
- out("");
3430
- }
3431
- function renderDep(d) {
3432
- if (d.status === "resolved") {
3433
- const providerLine = d.provider ? ` ${dim(`\u2192 install #${d.provider.installation_id} @ v${d.provider.version}`)}` : "";
3434
- out(` ${pc.green("\u25CF resolved")} ${dim("dep")} ${code(d.slug)}@${d.range ?? "*"}${providerLine}`);
3435
- } else {
3436
- out(
3437
- ` ${pc.yellow("\u25CB unbound")} ${dim("dep")} ${code(d.slug)}@${d.range ?? "*"} ${dim(`reason: ${d.reason ?? "(unspecified)"}`)}`
4340
+ if (type === "link_row" && result.target === void 0) {
4341
+ throw new FieldSpecError(
4342
+ `Type ${quote("link_row")} requires a ${quote("target")}. Use ${quote("--field-json")}.`
3438
4343
  );
3439
4344
  }
3440
- }
3441
- function renderBinding(b) {
3442
- if (b.status === "bound") {
3443
- const tableLine = b.table ? ` ${dim(`\u2192 ${b.table.name} (id ${b.table.id})`)}` : "";
3444
- out(` ${pc.green("\u25CF bound")} ${dim("uses")} ${code(b.uses_slug)}${tableLine}`);
3445
- } else if (b.status === "declined") {
3446
- out(` ${pc.dim("\u2298 declined")} ${dim("uses")} ${code(b.uses_slug)}`);
3447
- } else {
3448
- out(
3449
- ` ${pc.yellow("\u25CB unbound")} ${dim("uses")} ${code(b.uses_slug)} ${dim(`reason: ${b.reason ?? "(unspecified)"}`)}`
4345
+ if (type === "formula" || type === "ai") {
4346
+ throw new FieldSpecError(
4347
+ `Type ${quote(type)} requires extra config (${quote("formula")} or ${quote("prompt")}). Use ${quote("--field-json")}.`
3450
4348
  );
3451
4349
  }
4350
+ return result;
3452
4351
  }
3453
- async function safeGet(fn) {
4352
+ function parseFieldJson(json) {
4353
+ let parsed;
3454
4354
  try {
3455
- return await fn();
4355
+ parsed = JSON.parse(json);
3456
4356
  } catch (err) {
3457
- return null;
4357
+ throw new FieldSpecError(
4358
+ `--field-json value is not valid JSON: ${err.message}`
4359
+ );
3458
4360
  }
3459
- }
3460
-
3461
- // src/commands/dev.ts
3462
- init_config();
3463
- init_manifest_io();
3464
-
3465
- // src/lib/manifest-poll.ts
3466
- init_api_client();
3467
- function startManifestPollLoop(opts) {
3468
- const {
3469
- manifestPath: manifestPath2,
3470
- requestOptions,
3471
- intervalMs,
3472
- onUpdate,
3473
- onError
3474
- } = opts;
3475
- let lastSeenJson = existsSync(manifestPath2) ? readFileSync(manifestPath2, "utf-8") : "";
3476
- let timer = null;
3477
- let stopped = false;
3478
- const tick = async () => {
3479
- if (stopped) return;
3480
- try {
3481
- const { manifest } = await getInstallationSchema(requestOptions);
3482
- const remoteJson = JSON.stringify(manifest, null, 2) + "\n";
3483
- const currentLocal = existsSync(manifestPath2) ? readFileSync(manifestPath2, "utf-8") : "";
3484
- if (remoteJson === currentLocal) {
3485
- lastSeenJson = remoteJson;
3486
- } else if (remoteJson !== lastSeenJson) {
3487
- writeFileSync(manifestPath2, remoteJson, "utf-8");
3488
- lastSeenJson = remoteJson;
3489
- if (onUpdate) {
3490
- const delta = Buffer.byteLength(remoteJson, "utf8") - Buffer.byteLength(currentLocal, "utf8");
3491
- const deltaStr = delta === 0 ? "0 bytes" : `${delta > 0 ? "+" : ""}${delta} bytes`;
3492
- onUpdate(
3493
- `Backend manifest changed \u2014 synced to module.json (${deltaStr}).`
3494
- );
3495
- }
3496
- }
3497
- } catch (err) {
3498
- if (onError) {
3499
- const message = err instanceof ApiError ? `${err.code} (HTTP ${err.status}): ${err.message}` : err.message;
3500
- onError(new Error(message));
3501
- }
3502
- } finally {
3503
- if (!stopped) {
3504
- timer = setTimeout(() => void tick(), intervalMs);
3505
- }
3506
- }
3507
- };
3508
- timer = setTimeout(() => void tick(), intervalMs);
3509
- return {
3510
- stop: () => {
3511
- if (stopped) return;
3512
- stopped = true;
3513
- if (timer) {
3514
- clearTimeout(timer);
3515
- timer = null;
3516
- }
3517
- },
3518
- isRunning: () => !stopped
3519
- };
3520
- }
3521
-
3522
- // src/commands/dev.ts
3523
- init_output();
3524
- var DEFAULT_NEXT_PORT = 3e3;
3525
- var PREFIX_SDK = `\x1B[36m[sdk]\x1B[0m `;
3526
- var PREFIX_NEXT = `\x1B[35m[next]\x1B[0m `;
3527
- var PREFIX_POLL = `\x1B[33m[poll]\x1B[0m `;
3528
- var DEFAULT_REMOTE_POLL_INTERVAL_SEC = 5;
3529
- var MIN_REMOTE_POLL_INTERVAL_SEC = 1;
3530
- async function devCommand(options) {
3531
- if (options.sdkOnly && options.nextOnly) {
3532
- fail(
3533
- `Cannot pass both ${code("--sdk-only")} and ${code("--next-only")} \u2014 they're mutually exclusive.`
4361
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4362
+ throw new FieldSpecError("--field-json must be a JSON object.");
4363
+ }
4364
+ const o = parsed;
4365
+ if (typeof o.slug !== "string" || !SLUG_REGEX.test(o.slug)) {
4366
+ throw new FieldSpecError(
4367
+ `--field-json missing or invalid ${quote("slug")}. Must match ${quote(SLUG_REGEX.source)}.`
3534
4368
  );
3535
- return 1;
3536
4369
  }
3537
- const projectRoot = resolve(options.dir ?? process.cwd());
3538
- const manifestPath2 = `${projectRoot}/module.json`;
3539
- if (!existsSync(manifestPath2)) {
3540
- fail(`No ${code("module.json")} found in ${code(projectRoot)}.`);
3541
- info(`Run ${code("dashwise module init <slug>")} to scaffold a new module first.`);
3542
- return 1;
4370
+ if (typeof o.type !== "string" || !FIELD_TYPES.includes(o.type)) {
4371
+ throw new FieldSpecError(
4372
+ `--field-json missing or invalid ${quote("type")}. Allowed: ${FIELD_TYPES.join(", ")}.`
4373
+ );
4374
+ }
4375
+ return o;
4376
+ }
4377
+ function quote(s) {
4378
+ return `\`${s}\``;
4379
+ }
4380
+ var FIELD_TYPES, SLUG_REGEX, KNOWN_FLAGS, FieldSpecError;
4381
+ var init_field_spec = __esm({
4382
+ "src/lib/field-spec.ts"() {
4383
+ FIELD_TYPES = [
4384
+ "text",
4385
+ "long_text",
4386
+ "number",
4387
+ "boolean",
4388
+ "date",
4389
+ "datetime",
4390
+ "single_select",
4391
+ "multi_select",
4392
+ "link_row",
4393
+ "file",
4394
+ "multi_file",
4395
+ "email",
4396
+ "url",
4397
+ "phone",
4398
+ "formula",
4399
+ "ai"
4400
+ ];
4401
+ SLUG_REGEX = /^[a-z][a-z0-9_]*[a-z0-9]$/;
4402
+ KNOWN_FLAGS = /* @__PURE__ */ new Set(["required"]);
4403
+ FieldSpecError = class extends Error {
4404
+ constructor(message) {
4405
+ super(message);
4406
+ this.name = "FieldSpecError";
4407
+ }
4408
+ };
3543
4409
  }
3544
- const manifestSlug = (() => {
3545
- try {
3546
- return readManifest(projectRoot)?.module?.slug;
3547
- } catch {
3548
- return void 0;
3549
- }
3550
- })();
3551
- const savedConfig = (() => {
3552
- try {
3553
- return readConfig();
3554
- } catch {
3555
- return null;
3556
- }
3557
- })();
3558
- const apiToken = process.env.DASHWISE_API_TOKEN ?? savedConfig?.token;
3559
- if (!apiToken) {
3560
- fail(`Not authenticated. Run ${code("dashwise login")} first.`);
3561
- info(
3562
- "The unified dev loop reads your CLI token from ~/.config/dashwise/auth.json and injects it into both child processes so the SDK can authenticate without you editing .env.local."
3563
- );
4410
+ });
4411
+
4412
+ // src/commands/table/create.ts
4413
+ var create_exports = {};
4414
+ __export(create_exports, {
4415
+ surfaceFailure: () => surfaceFailure,
4416
+ syncLocalManifest: () => syncLocalManifest,
4417
+ tableCreateCommand: () => tableCreateCommand
4418
+ });
4419
+ async function tableCreateCommand(slug, options) {
4420
+ if (!slug) {
4421
+ fail("Usage: `dashwise table create <slug> --field <slug>:<type>[:required] [...]`");
3564
4422
  return 1;
3565
4423
  }
3566
- const apiUrl = options.apiUrl ?? process.env.DASHWISE_API_URL ?? savedConfig?.apiUrl ?? "http://localhost:3000";
3567
- let port;
4424
+ const fields = [];
3568
4425
  try {
3569
- port = await resolveDevPort(options.port);
4426
+ for (const spec of options.field ?? []) {
4427
+ fields.push(parseFieldSpec(spec));
4428
+ }
4429
+ for (const json of options.fieldJson ?? []) {
4430
+ fields.push(parseFieldJson(json));
4431
+ }
3570
4432
  } catch (err) {
3571
- fail(err.message);
3572
- return 1;
3573
- }
3574
- const explicitInstallationId = options.installationId ?? process.env.DASHWISE_INSTALLATION_ID;
3575
- const reuseDevSessionId = process.env.DASHWISE_DEV_SESSION_ID;
3576
- let moduleEntry = manifestSlug !== void 0 ? readModuleEntry(manifestSlug) : null;
3577
- const healed = manifestSlug !== void 0 ? await resolveModuleApiKey(manifestSlug, moduleEntry, apiUrl, apiToken) : null;
3578
- if (healed) moduleEntry = healed;
3579
- const apiKeyEnv = process.env.DASHWISE_API_KEY ?? moduleEntry?.apiKey;
3580
- let installationIdEnv;
3581
- let revokeDevSession;
3582
- if (explicitInstallationId && explicitInstallationId !== "local") {
3583
- installationIdEnv = explicitInstallationId;
3584
- } else if (reuseDevSessionId) ; else {
3585
- const effectiveWorkspace = options.workspace ?? moduleEntry?.workspaceId?.toString();
3586
- let workspaceContext;
3587
- try {
3588
- workspaceContext = await resolveTargetWorkspace(
3589
- apiUrl,
3590
- apiToken,
3591
- effectiveWorkspace
3592
- );
3593
- } catch (err) {
4433
+ if (err instanceof FieldSpecError) {
3594
4434
  fail(err.message);
3595
4435
  return 1;
3596
4436
  }
3597
- const callbackOrigins = computeCallbackOrigins(port, options.callbackOrigin ?? []);
3598
- try {
3599
- const session = await registerDevSession(apiUrl, apiToken, {
3600
- target_workspace_id: workspaceContext.id,
3601
- callback_origins: callbackOrigins
3602
- });
3603
- revokeDevSession = () => revokeDevSessionBestEffort(apiUrl, apiToken, session.id);
3604
- info(` ${dim("dev session")} ${session.id}`);
3605
- info(` ${dim("workspace")} ${workspaceContext.name} (id=${workspaceContext.id}, slug=${workspaceContext.slug})`);
3606
- info(` ${dim("callbacks")} ${callbackOrigins.join(", ")}`);
3607
- info(` ${dim("expires")} ${session.expires_at}`);
3608
- } catch (err) {
3609
- if (err instanceof ApiError) {
3610
- fail(`Failed to register dev session: ${err.code} \u2014 ${err.message}`);
3611
- } else {
3612
- fail(`Failed to register dev session: ${err.message}`);
3613
- }
4437
+ throw err;
4438
+ }
4439
+ if (fields.length === 0) {
4440
+ fail(
4441
+ `At least one field is required. Use ${code("--field <slug>:<type>")} (repeatable) or ${code("--field-json '{...}'")} for fields with options/target/default.`
4442
+ );
4443
+ return 1;
4444
+ }
4445
+ const seen = /* @__PURE__ */ new Set();
4446
+ for (const f of fields) {
4447
+ if (seen.has(f.slug)) {
4448
+ fail(`Duplicate field slug ${code(f.slug)} in --field flags.`);
3614
4449
  return 1;
3615
4450
  }
4451
+ seen.add(f.slug);
3616
4452
  }
3617
- const childEnv = {
3618
- ...process.env,
3619
- DASHWISE_API_URL: apiUrl,
3620
- NEXT_PUBLIC_DASHWISE_API_URL: apiUrl,
3621
- ...apiKeyEnv !== void 0 ? { DASHWISE_API_KEY: apiKeyEnv } : {}
3622
- };
3623
- info(`Dev loop starting \u2014 Ctrl-C to stop.`);
3624
- info(` ${dim("api")} ${apiUrl}`);
3625
- if (installationIdEnv !== void 0) {
3626
- info(` ${dim("installation")} ${installationIdEnv} (prod-mode SSO)`);
3627
- } else {
3628
- info(` ${dim("mode")} dev (dev SSO session registered)`);
4453
+ let ctx;
4454
+ try {
4455
+ ctx = loadSchemaCommandContext({
4456
+ ...options.dir !== void 0 ? { dir: options.dir } : {}
4457
+ });
4458
+ } catch (err) {
4459
+ fail(err.message);
4460
+ return 1;
3629
4461
  }
3630
- info(` ${dim("cli token")} ${maskedToken(apiToken)} ${dim(`(from ${apiToken === savedConfig?.token ? "~/.config/dashwise/auth.json" : "$DASHWISE_API_TOKEN"})`)}`);
3631
- if (apiKeyEnv !== void 0) {
3632
- const source = process.env.DASHWISE_API_KEY === apiKeyEnv ? "$DASHWISE_API_KEY" : `~/.config/dashwise/auth.json#modules[${manifestSlug}]`;
3633
- const installId = moduleEntry?.installationId;
3634
- info(
3635
- ` ${dim("api key")} ${maskedToken(apiKeyEnv)} ${dim(`(from ${source}${installId ? `, install id=${installId}` : ""})`)}`
3636
- );
3637
- } else if (manifestSlug !== void 0 && installationIdEnv === void 0) {
3638
- fail(
3639
- `No ${code("dwk_")} API key for module ${code(manifestSlug)} \u2014 the module will run with NO token.`
4462
+ info(`Creating table ${code(slug)} in installation ${ctx.entry.installationId}\u2026`);
4463
+ try {
4464
+ const result = await createTable(
4465
+ { slug, fields },
4466
+ ctx.requestOptions
3640
4467
  );
3641
- info(
3642
- ` Data-plane calls (db, qb) AND sign-in will fail until you provision a dev install:
3643
- ${code(`dashwise init ${manifestSlug} --force --workspace <your-workspace-slug>`)}
3644
- (Ensure you're logged into the right backend: ${code(`dashwise login --api-url ${apiUrl}`)}.)`
4468
+ success(
4469
+ `Created table ${code(slug)} (physical_table_id=${result.physical_table_id}, manifest_version=${result.manifest_version})`
3645
4470
  );
4471
+ } catch (err) {
4472
+ return surfaceFailure(err);
3646
4473
  }
3647
- info("");
3648
- if (moduleEntry !== null && manifestSlug !== void 0) {
3649
- await reportConnectStatus({
3650
- installationId: moduleEntry.installationId,
3651
- moduleSlug: manifestSlug,
3652
- requestOptions: { baseUrl: apiUrl, auth: { token: apiToken } }
3653
- });
4474
+ return syncLocalManifest(ctx);
4475
+ }
4476
+ async function syncLocalManifest(ctx) {
4477
+ try {
4478
+ const { manifest } = await getInstallationSchema(ctx.requestOptions);
4479
+ const local = (() => {
4480
+ try {
4481
+ return readManifest(ctx.projectRoot);
4482
+ } catch {
4483
+ return null;
4484
+ }
4485
+ })();
4486
+ const { merged, preserved } = mergeRemoteManifest(local, manifest);
4487
+ writeManifest(merged, ctx.projectRoot);
4488
+ if (preserved.length > 0) {
4489
+ info(PRESERVED_BLOCKS_NOTICE);
4490
+ }
4491
+ return 0;
4492
+ } catch (err) {
4493
+ if (err instanceof ApiError) {
4494
+ warn(
4495
+ `Schema sync to local module.json failed (${err.code} ${err.status}): ${err.message}. The mutation succeeded server-side; re-run your next command to retry the local sync.`
4496
+ );
4497
+ } else {
4498
+ warn(
4499
+ `Schema sync to local module.json failed: ${err.message}. The mutation succeeded server-side.`
4500
+ );
4501
+ }
4502
+ return 0;
3654
4503
  }
3655
- const children = [];
3656
- if (!options.nextOnly) {
3657
- const sdkChild = spawnSdkWatcher(projectRoot, childEnv);
3658
- children.push({ name: "sdk", child: sdkChild, prefix: PREFIX_SDK });
4504
+ }
4505
+ function surfaceFailure(err) {
4506
+ if (err instanceof ApiError) {
4507
+ switch (err.code) {
4508
+ case "TABLE_SLUG_EXISTS":
4509
+ fail(`A table with this slug already exists in the manifest.`);
4510
+ break;
4511
+ case "FIELD_SLUG_EXISTS":
4512
+ fail(`A field with this slug already exists on the table.`);
4513
+ break;
4514
+ case "TABLE_NOT_FOUND":
4515
+ fail(`Table not found in the manifest.`);
4516
+ break;
4517
+ case "FIELD_NOT_FOUND":
4518
+ fail(`Field not found on the table.`);
4519
+ break;
4520
+ case "UNAUTHENTICATED":
4521
+ fail(
4522
+ `API key rejected by backend. The cached key may be expired or revoked \u2014 re-run \`dashwise init <slug>\` to mint a fresh one (existing files are preserved).`
4523
+ );
4524
+ break;
4525
+ case "SCHEMA_LOCKED":
4526
+ fail(
4527
+ `Schema mutations require a local-dev install (kind=local). The cached API key belongs to a non-local install \u2014 schema is locked. Run \`dashwise init <slug>\` for a local dev install (existing files are preserved).`
4528
+ );
4529
+ break;
4530
+ case "INSTALL_MISMATCH":
4531
+ fail(
4532
+ `API key is bound to a different installation than the URL. The cached install id may be stale \u2014 re-run \`dashwise init <slug>\`.`
4533
+ );
4534
+ break;
4535
+ default:
4536
+ fail(`Backend rejected: ${err.code} ${err.status} \u2014 ${err.message}`);
4537
+ }
4538
+ return 1;
3659
4539
  }
3660
- if (!options.sdkOnly) {
3661
- const nextChild = spawnNextDev(projectRoot, childEnv, port);
3662
- children.push({ name: "next", child: nextChild, prefix: PREFIX_NEXT });
4540
+ fail(`Unexpected error: ${err.message}`);
4541
+ return 1;
4542
+ }
4543
+ var init_create = __esm({
4544
+ "src/commands/table/create.ts"() {
4545
+ init_manifest_io();
4546
+ init_api_client();
4547
+ init_manifest_merge();
4548
+ init_schema_command_context();
4549
+ init_field_spec();
4550
+ init_output();
3663
4551
  }
3664
- for (const { child, prefix } of children) {
3665
- pipeWithPrefix(child.stdout, process.stdout, prefix);
3666
- pipeWithPrefix(child.stderr, process.stderr, prefix);
4552
+ });
4553
+
4554
+ // src/commands/table/delete.ts
4555
+ var delete_exports = {};
4556
+ __export(delete_exports, {
4557
+ tableDeleteCommand: () => tableDeleteCommand
4558
+ });
4559
+ async function tableDeleteCommand(slug, options) {
4560
+ if (!slug) {
4561
+ fail("Usage: `dashwise table delete <slug>`");
4562
+ return 1;
3667
4563
  }
3668
- let manifestPoll = null;
3669
- const pollEntry = moduleEntry;
3670
- const enablePoll = !options.noRemotePoll && pollEntry !== null && apiKeyEnv !== void 0 && manifestSlug !== void 0;
3671
- if (enablePoll && pollEntry !== null) {
3672
- const intervalSec = Math.max(
3673
- MIN_REMOTE_POLL_INTERVAL_SEC,
3674
- options.remotePollIntervalSec ?? DEFAULT_REMOTE_POLL_INTERVAL_SEC
3675
- );
3676
- manifestPoll = startManifestPollLoop({
3677
- manifestPath: manifestPath2,
3678
- requestOptions: {
3679
- baseUrl: apiUrl,
3680
- // The `dwk_` key resolves the install server-side.
3681
- auth: { token: apiKeyEnv }
3682
- },
3683
- intervalMs: intervalSec * 1e3,
3684
- onUpdate: (msg) => {
3685
- process.stdout.write(`${PREFIX_POLL}${msg}
3686
- `);
3687
- },
3688
- onError: (err) => {
3689
- process.stderr.write(`${PREFIX_POLL}${err.message}
3690
- `);
3691
- }
4564
+ let ctx;
4565
+ try {
4566
+ ctx = loadSchemaCommandContext({
4567
+ ...options.dir !== void 0 ? { dir: options.dir } : {}
3692
4568
  });
3693
- info(
3694
- ` ${dim("poll")} backend manifest every ${intervalSec}s ${dim("(--no-remote-poll to disable)")}`
4569
+ } catch (err) {
4570
+ fail(err.message);
4571
+ return 1;
4572
+ }
4573
+ info(`Dropping table ${code(slug)} from installation ${ctx.entry.installationId}\u2026`);
4574
+ try {
4575
+ const result = await dropTable(slug, ctx.requestOptions);
4576
+ success(
4577
+ `Dropped table ${code(slug)} (manifest_version=${result.manifest_version}). Physical table soft-deleted; recoverable for 30 days via the dashboard's trash.`
3695
4578
  );
3696
- info("");
4579
+ } catch (err) {
4580
+ return surfaceFailure(err);
3697
4581
  }
3698
- let shuttingDown = false;
3699
- const forwardSignal = (sig) => {
3700
- if (shuttingDown) return;
3701
- shuttingDown = true;
3702
- if (manifestPoll) {
3703
- manifestPoll.stop();
3704
- manifestPoll = null;
3705
- }
3706
- info(`
3707
- Received ${sig}, stopping children\u2026`);
3708
- for (const { child } of children) {
3709
- if (child.exitCode === null && child.signalCode === null) {
3710
- child.kill(sig);
3711
- }
3712
- }
3713
- if (revokeDevSession) {
3714
- revokeDevSession().catch(() => {
3715
- });
3716
- }
3717
- };
3718
- process.on("SIGINT", () => forwardSignal("SIGINT"));
3719
- process.on("SIGTERM", () => forwardSignal("SIGTERM"));
3720
- const results = await Promise.all(
3721
- children.map(
3722
- ({ name, child }) => new Promise(
3723
- (resolveExit) => {
3724
- child.on("exit", (exitCode2, signal) => {
3725
- const killed = signal !== null;
3726
- const finalCode = exitCode2 ?? (killed ? 0 : 1);
3727
- resolveExit({ name, code: finalCode, killed });
3728
- });
3729
- }
3730
- )
3731
- )
3732
- );
3733
- const crashed = results.find((r) => !r.killed && r.code !== 0);
3734
- if (crashed && !shuttingDown) {
3735
- warn(`[${crashed.name}] exited with code ${crashed.code} \u2014 stopping others.`);
3736
- for (const { child } of children) {
3737
- if (child.exitCode === null && child.signalCode === null) {
3738
- child.kill("SIGTERM");
3739
- }
3740
- }
4582
+ return syncLocalManifest(ctx);
4583
+ }
4584
+ var init_delete = __esm({
4585
+ "src/commands/table/delete.ts"() {
4586
+ init_api_client();
4587
+ init_schema_command_context();
4588
+ init_output();
4589
+ init_create();
3741
4590
  }
3742
- if (manifestPoll) {
3743
- manifestPoll.stop();
3744
- manifestPoll = null;
4591
+ });
4592
+
4593
+ // src/commands/field/add.ts
4594
+ var add_exports = {};
4595
+ __export(add_exports, {
4596
+ fieldAddCommand: () => fieldAddCommand
4597
+ });
4598
+ async function fieldAddCommand(tableSlug, fieldSpec, options) {
4599
+ if (!tableSlug) {
4600
+ fail(
4601
+ "Usage: `dashwise field add <table-slug> <field-slug>:<type>[:required]` or `dashwise field add <table-slug> --field-json '{...}'`"
4602
+ );
4603
+ return 1;
3745
4604
  }
3746
- const exitCode = Math.max(0, ...results.map((r) => r.code));
3747
- if (exitCode === 0) {
3748
- success("Dev loop stopped cleanly.");
4605
+ const hasPositional = fieldSpec !== void 0;
4606
+ const hasJson = options.fieldJson !== void 0;
4607
+ if (!hasPositional && !hasJson) {
4608
+ fail(
4609
+ `Pass either a field spec (e.g. ${code("title:text:required")}) or ${code("--field-json")} with the field's JSON definition.`
4610
+ );
4611
+ return 1;
3749
4612
  }
3750
- return exitCode;
3751
- }
3752
- function spawnSdkWatcher(projectRoot, env) {
3753
- const child = spawn(
3754
- process.execPath,
3755
- [process.argv[1], "module", "dev", "--dir", projectRoot],
3756
- {
3757
- cwd: projectRoot,
3758
- env,
3759
- stdio: ["ignore", "pipe", "pipe"]
4613
+ if (hasPositional && hasJson) {
4614
+ fail(
4615
+ `Pass only ONE of the field spec or ${code("--field-json")} \u2014 they're mutually exclusive.`
4616
+ );
4617
+ return 1;
4618
+ }
4619
+ let field;
4620
+ try {
4621
+ field = hasJson ? parseFieldJson(options.fieldJson) : parseFieldSpec(fieldSpec);
4622
+ } catch (err) {
4623
+ if (err instanceof FieldSpecError) {
4624
+ fail(err.message);
4625
+ return 1;
3760
4626
  }
3761
- );
3762
- return child;
3763
- }
3764
- function spawnNextDev(projectRoot, env, port) {
3765
- const portArgs = port !== void 0 ? ["--", "--port", String(port)] : [];
3766
- const hasDevNextScript = hasNpmScript(projectRoot, "dev:next");
3767
- const [cmd, args] = hasDevNextScript ? ["npm", ["run", "dev:next", ...portArgs]] : ["npx", ["next", "dev", ...port !== void 0 ? ["--port", String(port)] : []]];
3768
- const child = spawn(cmd, args, {
3769
- cwd: projectRoot,
3770
- env,
3771
- stdio: ["ignore", "pipe", "pipe"],
3772
- // Windows requires `shell: true` for `npm` / `npx` because they're
3773
- // `.cmd` shims, not real binaries.
3774
- shell: process.platform === "win32"
3775
- });
3776
- return child;
3777
- }
3778
- function hasNpmScript(projectRoot, scriptName) {
4627
+ throw err;
4628
+ }
4629
+ let ctx;
3779
4630
  try {
3780
- const pkgPath = `${projectRoot}/package.json`;
3781
- if (!existsSync(pkgPath)) return false;
3782
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
3783
- return typeof pkg.scripts?.[scriptName] === "string";
3784
- } catch {
3785
- return false;
4631
+ ctx = loadSchemaCommandContext({
4632
+ ...options.dir !== void 0 ? { dir: options.dir } : {}
4633
+ });
4634
+ } catch (err) {
4635
+ fail(err.message);
4636
+ return 1;
3786
4637
  }
3787
- }
3788
- function pipeWithPrefix(src, dst, prefix) {
3789
- if (!src) return;
3790
- let leftover = "";
3791
- src.on("data", (chunk) => {
3792
- const text3 = typeof chunk === "string" ? chunk : chunk.toString("utf-8");
3793
- const combined = leftover + text3;
3794
- const lines = combined.split("\n");
3795
- leftover = lines.pop() ?? "";
3796
- for (const line of lines) {
3797
- dst.write(`${prefix}${line}
3798
- `);
3799
- }
3800
- });
3801
- src.on("end", () => {
3802
- if (leftover.length > 0) {
3803
- dst.write(`${prefix}${leftover}
3804
- `);
3805
- }
3806
- });
3807
- }
3808
- function maskedToken(token) {
3809
- if (token.length <= 12) return "****";
3810
- const prefix = token.slice(0, 4);
3811
- const tail = token.slice(-4);
3812
- return `${prefix}\u2026${tail}`;
3813
- }
3814
- async function resolveModuleApiKey(slug, entry, apiUrl, cliBearer) {
3815
- if (process.env.DASHWISE_API_KEY) return null;
3816
- if (!entry) return null;
3817
- if (entry.apiKey) return null;
3818
- if (!entry.installationId) return null;
3819
4638
  info(
3820
- `No usable ${code("dwk_")} API key cached for ${code(slug)} \u2014 minting a fresh one against dev install ${entry.installationId} (a stale CLI can cache an empty key)\u2026`
4639
+ `Adding field ${code(`${tableSlug}.${field.slug}`)} (${field.type}) to installation ${ctx.entry.installationId}\u2026`
3821
4640
  );
3822
- let minted;
3823
4641
  try {
3824
- minted = await createApiKey(
3825
- entry.installationId,
3826
- { name: "local-dev" },
3827
- { baseUrl: apiUrl, auth: { token: cliBearer } }
4642
+ const result = await addField(
4643
+ { table_slug: tableSlug, field },
4644
+ ctx.requestOptions
3828
4645
  );
3829
- } catch (err) {
3830
- if (err instanceof ApiError && err.code === "INSTALLATION_NOT_FOUND") {
3831
- warn(
3832
- `Could not upgrade credentials for ${code(slug)}: its dev install no longer exists. Run ${code(`dashwise init ${slug} --force`)} to re-provision (a clean re-register).`
3833
- );
3834
- return null;
3835
- }
3836
- const reason = err instanceof ApiError ? `${err.code}: ${err.message}` : err.message;
3837
- warn(
3838
- `Could not upgrade credentials for ${code(slug)} (${reason}). Data-plane calls may fail; run ${code(`dashwise init ${slug} --force`)} to refresh.`
4646
+ success(
4647
+ `Added field ${code(`${tableSlug}.${field.slug}`)} (manifest_version=${result.manifest_version})`
3839
4648
  );
3840
- return null;
4649
+ } catch (err) {
4650
+ return surfaceFailure(err);
3841
4651
  }
3842
- const upgraded = {
3843
- apiKey: minted.raw_key,
3844
- apiKeyId: minted.api_key.id,
3845
- installationId: entry.installationId,
3846
- expiresAt: minted.api_key.expires_at ?? entry.expiresAt
3847
- };
4652
+ return syncLocalManifest(ctx);
4653
+ }
4654
+ var init_add = __esm({
4655
+ "src/commands/field/add.ts"() {
4656
+ init_api_client();
4657
+ init_schema_command_context();
4658
+ init_field_spec();
4659
+ init_output();
4660
+ init_create();
4661
+ }
4662
+ });
4663
+
4664
+ // src/commands/field/remove.ts
4665
+ var remove_exports = {};
4666
+ __export(remove_exports, {
4667
+ fieldRemoveCommand: () => fieldRemoveCommand
4668
+ });
4669
+ async function fieldRemoveCommand(tableSlug, fieldSlug, options) {
4670
+ if (!tableSlug || !fieldSlug) {
4671
+ fail("Usage: `dashwise field remove <table-slug> <field-slug>`");
4672
+ return 1;
4673
+ }
4674
+ let ctx;
3848
4675
  try {
3849
- upsertModuleEntry(slug, upgraded);
4676
+ ctx = loadSchemaCommandContext({
4677
+ ...options.dir !== void 0 ? { dir: options.dir } : {}
4678
+ });
3850
4679
  } catch (err) {
3851
- warn(
3852
- `Minted a new API key for ${code(slug)} but failed to cache it: ${err.message}
3853
- API key (save manually): ${minted.raw_key}`
4680
+ fail(err.message);
4681
+ return 1;
4682
+ }
4683
+ info(
4684
+ `Removing field ${code(`${tableSlug}.${fieldSlug}`)} from installation ${ctx.entry.installationId}\u2026`
4685
+ );
4686
+ try {
4687
+ const result = await removeField(
4688
+ tableSlug,
4689
+ fieldSlug,
4690
+ ctx.requestOptions
4691
+ );
4692
+ success(
4693
+ `Removed field ${code(`${tableSlug}.${fieldSlug}`)} (manifest_version=${result.manifest_version})`
3854
4694
  );
4695
+ } catch (err) {
4696
+ return surfaceFailure(err);
3855
4697
  }
3856
- success(`Upgraded ${code(slug)} to a dwk_ API key (id=${minted.api_key.id}).`);
3857
- return upgraded;
3858
- }
3859
- function computeCallbackOrigins(port, extra) {
3860
- const def = `http://localhost:${port}`;
3861
- return Array.from(/* @__PURE__ */ new Set([def, ...extra]));
4698
+ return syncLocalManifest(ctx);
3862
4699
  }
3863
- var PORT_PROBE_LIMIT = 10;
3864
- async function resolveDevPort(requested) {
3865
- if (requested !== void 0) {
3866
- if (await isPortFree(requested)) return requested;
3867
- const suggestion = await findFirstFreePort(requested + 1, PORT_PROBE_LIMIT);
3868
- const suggestionHint = suggestion !== null ? `Try \`dashwise dev --port ${suggestion}\`.` : `No free port found between ${requested + 1} and ${requested + PORT_PROBE_LIMIT} either \u2014 check what's listening with \`lsof -i :${requested}\`.`;
3869
- throw new Error(
3870
- `Port ${requested} is already in use. ${suggestionHint}`
4700
+ var init_remove = __esm({
4701
+ "src/commands/field/remove.ts"() {
4702
+ init_api_client();
4703
+ init_schema_command_context();
4704
+ init_output();
4705
+ init_create();
4706
+ }
4707
+ });
4708
+
4709
+ // src/commands/field/set-type.ts
4710
+ var set_type_exports = {};
4711
+ __export(set_type_exports, {
4712
+ fieldSetTypeCommand: () => fieldSetTypeCommand
4713
+ });
4714
+ async function fieldSetTypeCommand(tableSlug, fieldSlug, newType, options) {
4715
+ if (!tableSlug || !fieldSlug || !newType) {
4716
+ fail(
4717
+ "Usage: `dashwise field set-type <table-slug> <field-slug> <new-type>`"
3871
4718
  );
4719
+ return 1;
3872
4720
  }
3873
- const found = await findFirstFreePort(DEFAULT_NEXT_PORT, PORT_PROBE_LIMIT);
3874
- if (found === null) {
3875
- throw new Error(
3876
- `No free port found between ${DEFAULT_NEXT_PORT} and ${DEFAULT_NEXT_PORT + PORT_PROBE_LIMIT - 1}. Free a port (e.g. \`lsof -i :${DEFAULT_NEXT_PORT}\`) or pass \`--port <N>\` explicitly.`
4721
+ if (!FIELD_TYPES.includes(newType)) {
4722
+ fail(
4723
+ `Type ${code(newType)} is not a known type. Allowed: ${FIELD_TYPES.join(", ")}.`
3877
4724
  );
4725
+ return 1;
3878
4726
  }
3879
- return found;
3880
- }
3881
- async function isPortFree(port) {
3882
- const { createServer } = await import('net');
3883
- return new Promise((resolve13) => {
3884
- const server = createServer();
3885
- server.once("error", () => {
3886
- resolve13(false);
3887
- });
3888
- server.once("listening", () => {
3889
- server.close(() => resolve13(true));
4727
+ let ctx;
4728
+ try {
4729
+ ctx = loadSchemaCommandContext({
4730
+ ...options.dir !== void 0 ? { dir: options.dir } : {}
3890
4731
  });
3891
- server.listen(port, "0.0.0.0");
3892
- });
3893
- }
3894
- async function findFirstFreePort(start, limit) {
3895
- for (let i = 0; i < limit; i++) {
3896
- const port = start + i;
3897
- if (await isPortFree(port)) return port;
4732
+ } catch (err) {
4733
+ fail(err.message);
4734
+ return 1;
3898
4735
  }
3899
- return null;
3900
- }
3901
- async function resolveTargetWorkspace(apiUrl, apiToken, flag) {
3902
- const workspaces = await apiRequest(
3903
- "GET",
3904
- "/api/workspaces",
3905
- void 0,
3906
- { baseUrl: apiUrl, auth: { token: apiToken } }
4736
+ info(
4737
+ `Setting type of ${code(`${tableSlug}.${fieldSlug}`)} \u2192 ${code(newType)} on installation ${ctx.entry.installationId}\u2026`
3907
4738
  );
3908
- if (workspaces.length === 0) {
3909
- throw new Error(
3910
- "No workspaces found for your account. Create a workspace at the DashWise dashboard first."
3911
- );
3912
- }
3913
- if (flag === void 0) {
3914
- if (workspaces.length === 1) {
3915
- return workspaces[0];
3916
- }
3917
- const list = workspaces.map((w) => ` - ${w.slug} (id=${w.id}, name="${w.name}")`).join("\n");
3918
- throw new Error(
3919
- `You have ${workspaces.length} workspaces. Re-run with --workspace <slug-or-id> to pick one:
3920
- ${list}`
4739
+ try {
4740
+ const result = await updateField(
4741
+ tableSlug,
4742
+ fieldSlug,
4743
+ { type: newType },
4744
+ ctx.requestOptions
3921
4745
  );
3922
- }
3923
- const asNumber = Number.parseInt(flag, 10);
3924
- const isNumeric = !Number.isNaN(asNumber) && String(asNumber) === flag;
3925
- const match = isNumeric ? workspaces.find((w) => w.id === asNumber) : workspaces.find((w) => w.slug === flag);
3926
- if (!match) {
3927
- const list = workspaces.map((w) => ` - ${w.slug} (id=${w.id})`).join("\n");
3928
- throw new Error(
3929
- `No workspace matching --workspace=${flag}. Available:
3930
- ${list}`
4746
+ success(
4747
+ `Updated ${code(`${tableSlug}.${fieldSlug}`)} \u2192 ${code(newType)} (manifest_version=${result.manifest_version})`
3931
4748
  );
4749
+ } catch (err) {
4750
+ return surfaceFailure(err);
3932
4751
  }
3933
- return match;
3934
- }
3935
- async function registerDevSession(apiUrl, apiToken, body) {
3936
- return apiRequest(
3937
- "POST",
3938
- "/api/auth/dev/sessions",
3939
- body,
3940
- { baseUrl: apiUrl, auth: { token: apiToken } }
3941
- );
3942
- }
3943
- async function revokeDevSessionBestEffort(apiUrl, apiToken, id) {
3944
- await apiRequest(
3945
- "DELETE",
3946
- `/api/auth/dev/sessions/${encodeURIComponent(id)}`,
3947
- void 0,
3948
- { baseUrl: apiUrl, auth: { token: apiToken } }
3949
- );
4752
+ return syncLocalManifest(ctx);
3950
4753
  }
4754
+ var init_set_type = __esm({
4755
+ "src/commands/field/set-type.ts"() {
4756
+ init_api_client();
4757
+ init_schema_command_context();
4758
+ init_field_spec();
4759
+ init_output();
4760
+ init_create();
4761
+ }
4762
+ });
4763
+
4764
+ // src/bin.ts
4765
+ init_dev();
3951
4766
 
3952
4767
  // src/commands/login.ts
3953
4768
  init_api_client();
@@ -5534,6 +6349,7 @@ async function moduleInstallCommand(slugArg, options) {
5534
6349
  if (isInstallGated(result)) {
5535
6350
  if (options.json) {
5536
6351
  out(JSON.stringify(result));
6352
+ if (devInstallReplaced) printDevInstallAftermath(mod.slug, workspace2.slug);
5537
6353
  return 0;
5538
6354
  }
5539
6355
  success(
@@ -5542,6 +6358,7 @@ async function moduleInstallCommand(slugArg, options) {
5542
6358
  info(
5543
6359
  `This module requires author approval before it installs. Once approved, check with ${code(`dashwise module status ${mod.slug} --workspace ${workspace2.slug}`)}.`
5544
6360
  );
6361
+ if (devInstallReplaced) printDevInstallAftermath(mod.slug, workspace2.slug);
5545
6362
  return 0;
5546
6363
  }
5547
6364
  let row;
@@ -5566,6 +6383,7 @@ async function moduleInstallCommand(slugArg, options) {
5566
6383
  verified
5567
6384
  })
5568
6385
  );
6386
+ if (devInstallReplaced) printDevInstallAftermath(mod.slug, workspace2.slug);
5569
6387
  return 0;
5570
6388
  }
5571
6389
  if (!verified) {
@@ -5575,6 +6393,7 @@ async function moduleInstallCommand(slugArg, options) {
5575
6393
  info(
5576
6394
  `The verification read failed \u2014 run ${code(`dashwise module status ${mod.slug} --workspace ${workspace2.slug}`)} to see the deploy state and public URL.`
5577
6395
  );
6396
+ if (devInstallReplaced) printDevInstallAftermath(mod.slug, workspace2.slug);
5578
6397
  return 0;
5579
6398
  }
5580
6399
  if (runtimeProvisioned && publicUrl) {
@@ -5599,8 +6418,21 @@ async function moduleInstallCommand(slugArg, options) {
5599
6418
  `No public URL yet \u2014 runtime provisioning didn't complete. Re-running ${code("dashwise module install")} is idempotent and repairs a failed provision.`
5600
6419
  );
5601
6420
  }
6421
+ if (devInstallReplaced) printDevInstallAftermath(mod.slug, workspace2.slug);
5602
6422
  return 0;
5603
6423
  }
6424
+ function publishedModuleLocalDevGuidance(slug) {
6425
+ return `Re-provision local dev any time with ${code(`dashwise init ${slug} --workspace <ws>`)} (or ${code("dashwise module reset-dev-install")}) \u2014 requires backend \u2265 2026-07-15; the fresh dev install COEXISTS with the production install.
6426
+ Older backends 409 SLUG_ALREADY_EXISTS on that path (no re-provision anywhere). Fallback for them (production repair):
6427
+ - Develop against the production install with a scoped key: ${code(`dashwise api-keys create local-dev --install <id> --full-access`)} + ${code(`dashwise origins add http://localhost:3000 --install <id>`)} (id via ${code(`dashwise module status ${slug}`)}), then run bare ${code("next dev")} \u2014 and CLEAN UP after (revoke the key, remove the origin).
6428
+ - Or duplicate the code under a NEW slug for greenfield iteration.`;
6429
+ }
6430
+ function printDevInstallAftermath(slug, workspaceSlug) {
6431
+ warn(
6432
+ `Local dev for ${code(slug)} is DISCONNECTED (the dev install in ${code(workspaceSlug)} and its ${code("dwk_")} key were destroyed).
6433
+ ${publishedModuleLocalDevGuidance(slug)}`
6434
+ );
6435
+ }
5604
6436
  function resolveSlug(slugArg, dir) {
5605
6437
  if (slugArg) return slugArg;
5606
6438
  try {
@@ -5621,11 +6453,14 @@ async function resolveDevInstallCollision(existing, slug, workspaceSlug, options
5621
6453
  return 1;
5622
6454
  }
5623
6455
  const explanation = `Workspace ${code(workspaceSlug)} already has the DEV install of ${code(slug)} (kind=local, id=${existing.id}) created by ${code("dashwise init")}.
5624
- Only ONE install per (workspace, module) can exist, and ${code("kind")} is immutable: a plain install would upgrade that row in place \u2014 the runtime would provision, but it would STAY a dev install, hidden from the app sidebar.
6456
+ Install ${code("kind")} is immutable: a plain install could land on that row's upgrade path \u2014 the runtime would provision, but it would STAY a dev install, hidden from the app sidebar.
5625
6457
  Options:
5626
- - ${code("--replace-dev-install")} \u2014 destroy the dev install, then fresh production install. Recommended when development is done. Note: this revokes the module's cached local-dev credentials, so local ${code("dashwise dev")} against this workspace needs a fresh ${code("dashwise init --force")} afterwards (which in turn cannot coexist with the production install here).
6458
+ - ${code("--replace-dev-install")} \u2014 destroy the dev install, then fresh production install. Recommended when development is done. Local dev can be re-provisioned afterwards with ${code(`dashwise init ${slug} --workspace <ws>`)} on backends \u2265 2026-07-15 (dev + production installs coexist); on OLDER backends this is one-way \u2014 no re-provision anywhere once published (interim: a scoped key on the production install via ${code("api-keys create --install <id> --full-access")}, or duplicate the code under a NEW slug).
5627
6459
  - ${code("--workspace <other>")} \u2014 deploy to a separate test/client workspace and keep this dev install. Recommended for ongoing development.`;
5628
- if (options.replaceDevInstall) return "replace";
6460
+ if (options.replaceDevInstall) {
6461
+ info(explanation);
6462
+ return "replace";
6463
+ }
5629
6464
  if (process.stdout.isTTY && !options.yes) {
5630
6465
  info(explanation);
5631
6466
  const answer = await select({
@@ -5634,7 +6469,7 @@ async function resolveDevInstallCollision(existing, slug, workspaceSlug, options
5634
6469
  {
5635
6470
  value: "replace",
5636
6471
  label: "Replace \u2014 destroy the dev install, then production-install",
5637
- hint: "local `dashwise dev` here will need `dashwise init --force` afterwards"
6472
+ hint: "revokes local-dev credentials; re-provision later via `dashwise init` (backend \u2265 2026-07-15 \u2014 older backends: scoped-key repair only)"
5638
6473
  },
5639
6474
  { value: "cancel", label: "Cancel \u2014 I will deploy to another workspace" }
5640
6475
  ],
@@ -5670,9 +6505,7 @@ async function replaceDevInstall(existing, slug, auth) {
5670
6505
  if (entry && entry.installationId === existing.id) {
5671
6506
  try {
5672
6507
  removeModuleEntry(slug);
5673
- info(
5674
- `Removed ${code(slug)} from auth.json \u2014 its local-dev credentials are revoked. Local ${code("dashwise dev")} needs ${code("dashwise init --force")} (ideally against a different workspace).`
5675
- );
6508
+ info(`Removed ${code(slug)} from auth.json \u2014 its local-dev credentials are revoked.`);
5676
6509
  } catch (err) {
5677
6510
  warn(
5678
6511
  `Dev install destroyed but auth.json cleanup failed: ${err.message}. Remove the ${code(`modules["${slug}"]`)} entry from ${code("~/.config/dashwise/auth.json")} manually.`
@@ -5721,7 +6554,8 @@ function surfaceInstallError(err, slug, workspaceSlug, devInstallReplaced) {
5721
6554
  }
5722
6555
  if (devInstallReplaced) {
5723
6556
  warn(
5724
- `Note: the dev install was already removed (that is not rolled back). Re-run ${code("dashwise module install")} to complete the production install, or ${code("dashwise init --force")} to restore local dev.`
6557
+ `Note: the dev install was already removed (that is not rolled back). Re-run ${code("dashwise module install")} to complete the production install.
6558
+ ${publishedModuleLocalDevGuidance(slug)}`
5725
6559
  );
5726
6560
  }
5727
6561
  return 1;
@@ -6214,7 +7048,131 @@ async function moduleStatusCommand(slugArg, options) {
6214
7048
  }
6215
7049
  if (!slug) {
6216
7050
  fail(
6217
- `No module slug \u2014 pass one (${code("dashwise module status <slug>")}) or run from a directory with a valid module.json.`
7051
+ `No module slug \u2014 pass one (${code("dashwise module status <slug>")}) or run from a directory with a valid module.json.`
7052
+ );
7053
+ return 1;
7054
+ }
7055
+ if (!isLoggedIn()) {
7056
+ fail(`Not logged in. Run ${code("dashwise login")} first.`);
7057
+ return 1;
7058
+ }
7059
+ const cfg = readConfig();
7060
+ if (!cfg) {
7061
+ fail(`CLI config missing. Run ${code("dashwise login")}.`);
7062
+ return 1;
7063
+ }
7064
+ const auth = { baseUrl: resolveApiUrl(), auth: { token: cfg.token } };
7065
+ let mod;
7066
+ try {
7067
+ mod = await getModuleBySlug(slug, auth);
7068
+ } catch (err) {
7069
+ if (err instanceof ApiError && err.status === 404) {
7070
+ fail(`Module ${code(slug)} not found on the backend.`);
7071
+ return 1;
7072
+ }
7073
+ fail(`Could not resolve module ${code(slug)}: ${err.message}`);
7074
+ return 1;
7075
+ }
7076
+ let workspace2;
7077
+ try {
7078
+ workspace2 = await resolveWorkspaceWithFallback({
7079
+ userFlag: options.workspace,
7080
+ cachedWorkspaceId: readModuleEntry(slug)?.workspaceId,
7081
+ configDefault: cfg.workspaceSlug,
7082
+ apiUrl: resolveApiUrl(),
7083
+ apiToken: cfg.token
7084
+ });
7085
+ } catch (err) {
7086
+ fail(`Workspace selection failed: ${err.message}`);
7087
+ return 1;
7088
+ }
7089
+ let row;
7090
+ try {
7091
+ const installs = await listWorkspaceInstallations(workspace2.id, auth);
7092
+ row = installs.find((r) => r.module_id === mod.id);
7093
+ } catch (err) {
7094
+ fail(`Could not read workspace installations: ${err.message}`);
7095
+ return 1;
7096
+ }
7097
+ const latestVersion = row?.module?.current_version ?? mod.current_version;
7098
+ if (!row) {
7099
+ if (options.json) {
7100
+ out(
7101
+ JSON.stringify({
7102
+ installed: false,
7103
+ module: mod.slug,
7104
+ workspace: { id: workspace2.id, slug: workspace2.slug },
7105
+ latest_version: latestVersion
7106
+ })
7107
+ );
7108
+ return 0;
7109
+ }
7110
+ info(
7111
+ `${code(mod.slug)} is not installed in workspace ${code(workspace2.slug)}.` + (latestVersion ? ` Deploy it with ${code(`dashwise module install ${mod.slug} --workspace ${workspace2.slug}`)}.` : ` It has no published version yet \u2014 run ${code("dashwise module publish")} first.`)
7112
+ );
7113
+ return 0;
7114
+ }
7115
+ const runtimeProvisioned = isRealRuntimeUrl(row.runtime_url);
7116
+ const state = runtimeProvisioned && row.public_url ? "live" : runtimeProvisioned ? "deployed" : "not_deployed";
7117
+ const installedVersion = row.module_version?.version ?? null;
7118
+ const updateAvailable = isUpdateAvailable(installedVersion, latestVersion);
7119
+ if (options.json) {
7120
+ out(
7121
+ JSON.stringify({
7122
+ installed: true,
7123
+ module: mod.slug,
7124
+ workspace: { id: workspace2.id, slug: workspace2.slug },
7125
+ installation_id: row.id,
7126
+ kind: row.kind ?? null,
7127
+ version: installedVersion,
7128
+ latest_version: latestVersion,
7129
+ update_available: updateAvailable,
7130
+ runtime_provisioned: runtimeProvisioned,
7131
+ runtime_url: row.runtime_url,
7132
+ public_url: row.public_url,
7133
+ state
7134
+ })
7135
+ );
7136
+ return 0;
7137
+ }
7138
+ const stateLabel = state === "live" ? "Live" : state === "deployed" ? "Deployed" : "Not deployed \u2014 runtime not provisioned";
7139
+ out("");
7140
+ kv("Module:", `${mod.slug} ${dim(`(id ${mod.id})`)}`);
7141
+ kv("Workspace:", `${workspace2.slug} ${dim(`(id ${workspace2.id})`)}`);
7142
+ kv("Install:", `#${row.id} ${dim(`(kind=${row.kind ?? "unknown"})`)}`);
7143
+ kv(
7144
+ "Version:",
7145
+ `${installedVersion ?? dim("(unknown)")}${updateAvailable ? ` ${code(`update available: v${latestVersion} \u2014 run \`dashwise module install ${mod.slug}\``)}` : latestVersion ? ` ${dim(`(latest published: v${latestVersion})`)}` : ""}`
7146
+ );
7147
+ kv(
7148
+ "Runtime:",
7149
+ runtimeProvisioned ? `provisioned ${dim(`(${row.runtime_url})`)}` : "not provisioned"
7150
+ );
7151
+ kv("Public URL:", row.public_url ?? dim("(none)"));
7152
+ kv("State:", stateLabel);
7153
+ out("");
7154
+ if (row.kind === "local") {
7155
+ info(
7156
+ `This is the ${code("kind=local")} dev install \u2014 hidden from the app sidebar. A production deploy needs ${code("dashwise module install --replace-dev-install")} (or ${code("--workspace <other>")}).`
7157
+ );
7158
+ } else if (state === "not_deployed") {
7159
+ info(
7160
+ `Re-run ${code(`dashwise module install ${mod.slug} --workspace ${workspace2.slug}`)} to (re-)provision the runtime \u2014 the install POST is idempotent.`
7161
+ );
7162
+ }
7163
+ return 0;
7164
+ }
7165
+
7166
+ // src/commands/module/uninstall.ts
7167
+ init_api_client();
7168
+ init_config();
7169
+ init_manifest_io();
7170
+ init_output();
7171
+ async function moduleUninstallCommand(slugArg, options) {
7172
+ const slug = resolveSlug2(slugArg, options.dir);
7173
+ if (!slug) {
7174
+ fail(
7175
+ `No module slug \u2014 pass one (${code("dashwise module uninstall <slug>")}) or run from a directory with a valid module.json.`
6218
7176
  );
6219
7177
  return 1;
6220
7178
  }
@@ -6233,7 +7191,7 @@ async function moduleStatusCommand(slugArg, options) {
6233
7191
  mod = await getModuleBySlug(slug, auth);
6234
7192
  } catch (err) {
6235
7193
  if (err instanceof ApiError && err.status === 404) {
6236
- fail(`Module ${code(slug)} not found on the backend.`);
7194
+ fail(`Module ${code(slug)} not found on the backend \u2014 nothing to uninstall.`);
6237
7195
  return 1;
6238
7196
  }
6239
7197
  fail(`Could not resolve module ${code(slug)}: ${err.message}`);
@@ -6260,74 +7218,119 @@ async function moduleStatusCommand(slugArg, options) {
6260
7218
  fail(`Could not read workspace installations: ${err.message}`);
6261
7219
  return 1;
6262
7220
  }
6263
- const latestVersion = row?.module?.current_version ?? mod.current_version;
6264
7221
  if (!row) {
6265
- if (options.json) {
6266
- out(
6267
- JSON.stringify({
6268
- installed: false,
6269
- module: mod.slug,
6270
- workspace: { id: workspace2.id, slug: workspace2.slug },
6271
- latest_version: latestVersion
6272
- })
7222
+ fail(
7223
+ `${code(mod.slug)} is not installed in workspace ${code(workspace2.slug)} \u2014 nothing to uninstall.`
7224
+ );
7225
+ return 1;
7226
+ }
7227
+ if (row.kind === "local") {
7228
+ fail(
7229
+ `Install ${code(`#${row.id}`)} of ${code(mod.slug)} in ${code(workspace2.slug)} is the ${code("kind=local")} DEV install backing local development.
7230
+ Use ${code("dashwise module destroy-dev-install")} (or ${code("dashwise module reset-dev-install")} for a clean re-provision) instead \u2014 it also revokes the cached local-dev credentials.`
7231
+ );
7232
+ return 1;
7233
+ }
7234
+ const scriptedOk = options.yes === true && options.workspace !== void 0;
7235
+ if (!scriptedOk) {
7236
+ if (!process.stdout.isTTY) {
7237
+ fail(
7238
+ `Refusing to uninstall without confirmation. In scripts, pass BOTH ${code("--yes")} AND an explicit ${code("--workspace <slug-or-id>")} (a bare ${code("--yes")} never uninstalls from an implicitly-resolved workspace).`
6273
7239
  );
6274
- return 0;
7240
+ return 1;
6275
7241
  }
6276
7242
  info(
6277
- `${code(mod.slug)} is not installed in workspace ${code(workspace2.slug)}.` + (latestVersion ? ` Deploy it with ${code(`dashwise module install ${mod.slug} --workspace ${workspace2.slug}`)}.` : ` It has no published version yet \u2014 run ${code("dashwise module publish")} first.`)
7243
+ `Uninstalling ${code(mod.slug)} (install ${code(`#${row.id}`)}, kind=${row.kind ?? "production"}${row.module_version?.version ? `, v${row.module_version.version}` : ""}) from workspace ${code(workspace2.slug)}.
7244
+ The app is soft-deleted, recoverable ONLY by a PLATFORM OPERATOR (contact support/HQ) within ~7 days \u2014 it does NOT appear in your dashboard trash.
7245
+ An operator restore KEEPS the public URL${row.public_url ? ` ${code(row.public_url)}` : ""}; a REINSTALL mints a NEW one \u2014 deep links to the old URL break.`
6278
7246
  );
6279
- return 0;
7247
+ const answer = await select({
7248
+ message: `Uninstall ${code(mod.slug)} from ${code(workspace2.slug)}?`,
7249
+ options: [
7250
+ { value: "uninstall", label: "Uninstall \u2014 soft-delete the app (operator-restorable ~7 days)" },
7251
+ { value: "cancel", label: "Cancel \u2014 nothing is changed" }
7252
+ ],
7253
+ initialValue: "cancel"
7254
+ });
7255
+ if (isCancel(answer) || answer === "cancel") {
7256
+ fail("Aborted. Nothing was changed.");
7257
+ return 130;
7258
+ }
7259
+ const typed = await text({
7260
+ message: `Type the module slug (${code(mod.slug)}) to confirm:`
7261
+ });
7262
+ if (isCancel(typed) || typed !== mod.slug) {
7263
+ fail(
7264
+ `Confirmation did not match ${code(mod.slug)}. Aborted \u2014 nothing was changed.`
7265
+ );
7266
+ return 130;
7267
+ }
6280
7268
  }
6281
- const runtimeProvisioned = isRealRuntimeUrl(row.runtime_url);
6282
- const state = runtimeProvisioned && row.public_url ? "live" : runtimeProvisioned ? "deployed" : "not_deployed";
6283
- const installedVersion = row.module_version?.version ?? null;
6284
- const updateAvailable = isUpdateAvailable(installedVersion, latestVersion);
6285
- if (options.json) {
6286
- out(
6287
- JSON.stringify({
6288
- installed: true,
6289
- module: mod.slug,
6290
- workspace: { id: workspace2.id, slug: workspace2.slug },
6291
- installation_id: row.id,
6292
- kind: row.kind ?? null,
6293
- version: installedVersion,
6294
- latest_version: latestVersion,
6295
- update_available: updateAvailable,
6296
- runtime_provisioned: runtimeProvisioned,
6297
- runtime_url: row.runtime_url,
6298
- public_url: row.public_url,
6299
- state
6300
- })
7269
+ try {
7270
+ await uninstallModuleInstallation(
7271
+ row.id,
7272
+ { ...options.parkConsumers ? { parkConsumers: true } : {} },
7273
+ auth
6301
7274
  );
6302
- return 0;
7275
+ } catch (err) {
7276
+ return surfaceUninstallError(err, mod.slug, workspace2.slug);
6303
7277
  }
6304
- const stateLabel = state === "live" ? "Live" : state === "deployed" ? "Deployed" : "Not deployed \u2014 runtime not provisioned";
6305
- out("");
6306
- kv("Module:", `${mod.slug} ${dim(`(id ${mod.id})`)}`);
6307
- kv("Workspace:", `${workspace2.slug} ${dim(`(id ${workspace2.id})`)}`);
6308
- kv("Install:", `#${row.id} ${dim(`(kind=${row.kind ?? "unknown"})`)}`);
6309
- kv(
6310
- "Version:",
6311
- `${installedVersion ?? dim("(unknown)")}${updateAvailable ? ` ${code(`update available: v${latestVersion} \u2014 run \`dashwise module install ${mod.slug}\``)}` : latestVersion ? ` ${dim(`(latest published: v${latestVersion})`)}` : ""}`
7278
+ success(
7279
+ `Uninstalled ${code(mod.slug)} (install ${code(`#${row.id}`)}) from workspace ${code(workspace2.slug)}.`
6312
7280
  );
6313
- kv(
6314
- "Runtime:",
6315
- runtimeProvisioned ? `provisioned ${dim(`(${row.runtime_url})`)}` : "not provisioned"
7281
+ info(
7282
+ `The app was soft-deleted. If this was a mistake: only a PLATFORM OPERATOR can restore it (contact support/HQ) within ~7 days \u2014 it does NOT appear in your dashboard trash. An operator restore keeps the same public URL.
7283
+ Reinstalling instead (${code(`dashwise module install ${mod.slug} --workspace ${workspace2.slug}`)}) mints a NEW public URL.`
6316
7284
  );
6317
- kv("Public URL:", row.public_url ?? dim("(none)"));
6318
- kv("State:", stateLabel);
6319
- out("");
6320
- if (row.kind === "local") {
6321
- info(
6322
- `This is the ${code("kind=local")} dev install \u2014 hidden from the app sidebar. A production deploy needs ${code("dashwise module install --replace-dev-install")} (or ${code("--workspace <other>")}).`
6323
- );
6324
- } else if (state === "not_deployed") {
7285
+ if (options.parkConsumers) {
6325
7286
  info(
6326
- `Re-run ${code(`dashwise module install ${mod.slug} --workspace ${workspace2.slug}`)} to (re-)provision the runtime \u2014 the install POST is idempotent.`
7287
+ `Consumer modules that depended on this install were parked (reason PROVIDER_UNINSTALLED) \u2014 they keep running with the dependency unbound until re-bound.`
6327
7288
  );
6328
7289
  }
6329
7290
  return 0;
6330
7291
  }
7292
+ function resolveSlug2(slugArg, dir) {
7293
+ if (slugArg) return slugArg;
7294
+ try {
7295
+ const manifest = readManifest(dir);
7296
+ const slug = manifest?.module?.slug;
7297
+ return typeof slug === "string" && slug.length > 0 ? slug : void 0;
7298
+ } catch {
7299
+ return void 0;
7300
+ }
7301
+ }
7302
+ function surfaceUninstallError(err, slug, workspaceSlug) {
7303
+ if (err instanceof ApiError) {
7304
+ if (err.status === 404 || err.code === "NOT_FOUND" || err.code === "INSTALLATION_NOT_FOUND") {
7305
+ warn(`The install is already gone server-side \u2014 nothing to do.`);
7306
+ return 0;
7307
+ }
7308
+ switch (err.code) {
7309
+ case "PROVIDER_HAS_CONSUMERS":
7310
+ fail(
7311
+ `${code(slug)} provides data to other installed modules \u2014 uninstalling it would break them.
7312
+ Re-run with ${code("--park-consumers")} to uninstall anyway: each consumer's dependency is parked (reason PROVIDER_UNINSTALLED) and the consumers keep running with that dependency unbound.`
7313
+ );
7314
+ break;
7315
+ case "NOT_WORKSPACE_ADMIN":
7316
+ case "FORBIDDEN":
7317
+ fail(
7318
+ `You must be an ADMIN of workspace ${code(workspaceSlug)} to uninstall modules from it.`
7319
+ );
7320
+ break;
7321
+ case "UNAUTHENTICATED":
7322
+ fail(
7323
+ `Auth rejected by the backend \u2014 your CLI token may be stale. Re-run ${code("dashwise login")} and try again.`
7324
+ );
7325
+ break;
7326
+ default:
7327
+ fail(`Uninstall failed (${err.code} ${err.status}): ${err.message}`);
7328
+ }
7329
+ return 1;
7330
+ }
7331
+ fail(`Uninstall failed: ${err.message}`);
7332
+ return 1;
7333
+ }
6331
7334
 
6332
7335
  // src/commands/module/pull-data.ts
6333
7336
  init_api_client();
@@ -7099,7 +8102,7 @@ function loadContext(opts) {
7099
8102
  const entry = readModuleEntry(slug);
7100
8103
  if (!entry) {
7101
8104
  throw new Error(
7102
- `No dev install cached for module "${slug}". Run \`dashwise init ${slug} --force\` to provision one.`
8105
+ `No dev install cached for module "${slug}". Run \`dashwise init ${slug}\` to provision one (existing files are preserved).`
7103
8106
  );
7104
8107
  }
7105
8108
  return {
@@ -7324,7 +8327,7 @@ function loadContext2(opts) {
7324
8327
  const entry = readModuleEntry(slug);
7325
8328
  if (!entry) {
7326
8329
  throw new Error(
7327
- `No dev install cached for module "${slug}". Run \`dashwise init ${slug} --force\` to provision one.`
8330
+ `No dev install cached for module "${slug}". Run \`dashwise init ${slug}\` to provision one (existing files are preserved).`
7328
8331
  );
7329
8332
  }
7330
8333
  return {
@@ -7916,7 +8919,7 @@ function loadContext3(opts) {
7916
8919
  const entry = readModuleEntry(moduleSlug);
7917
8920
  if (!entry) {
7918
8921
  throw new Error(
7919
- `No dev install cached for module "${moduleSlug}". Run \`dashwise init ${moduleSlug} --force\` to provision one.`
8922
+ `No dev install cached for module "${moduleSlug}". Run \`dashwise init ${moduleSlug}\` to provision one (existing files are preserved).`
7920
8923
  );
7921
8924
  }
7922
8925
  return {
@@ -8112,17 +9115,17 @@ function surfacePullFailure(err) {
8112
9115
  switch (err.code) {
8113
9116
  case "UNAUTHENTICATED":
8114
9117
  fail(
8115
- `Runtime token rejected. The cached token for this module may be expired \u2014 re-run \`dashwise init <slug> --force\` to mint a fresh one.`
9118
+ `Runtime token rejected. The cached token for this module may be expired \u2014 re-run \`dashwise init <slug>\` to mint a fresh one (existing files are preserved).`
8116
9119
  );
8117
9120
  break;
8118
9121
  case "SANDBOX_TOKEN_NOT_ACCEPTED":
8119
9122
  fail(
8120
- `Schema-read endpoint requires a local-dev runtime token (kind=local). The cached token is the wrong kind \u2014 re-run \`dashwise init <slug> --force\`.`
9123
+ `Schema-read endpoint requires a local-dev runtime token (kind=local). The cached token is the wrong kind \u2014 re-run \`dashwise init <slug>\`.`
8121
9124
  );
8122
9125
  break;
8123
9126
  case "INSTALL_MISMATCH":
8124
9127
  fail(
8125
- `Runtime token is bound to a different installation than expected. The cached install id may be stale \u2014 re-run \`dashwise init <slug> --force\`.`
9128
+ `Runtime token is bound to a different installation than expected. The cached install id may be stale \u2014 re-run \`dashwise init <slug>\`.`
8126
9129
  );
8127
9130
  break;
8128
9131
  case "INSTALLATION_NOT_FOUND":
@@ -8148,6 +9151,32 @@ init_config();
8148
9151
  init_manifest_io();
8149
9152
  init_output();
8150
9153
  function loadContext4(opts) {
9154
+ const cfg = readConfig();
9155
+ if (!cfg) {
9156
+ throw new Error("Not logged in. Run `dashwise login` first.");
9157
+ }
9158
+ const requestOptions = {
9159
+ baseUrl: resolveApiUrl(),
9160
+ auth: { token: cfg.token }
9161
+ };
9162
+ if (opts.install !== void 0) {
9163
+ if (!Number.isInteger(opts.install) || opts.install <= 0) {
9164
+ throw new Error(
9165
+ `--install expects a positive integer installation id (got "${opts.install}").`
9166
+ );
9167
+ }
9168
+ let slug2;
9169
+ try {
9170
+ slug2 = readManifest(opts.dir)?.module?.slug;
9171
+ } catch {
9172
+ }
9173
+ return {
9174
+ installationId: opts.install,
9175
+ slug: slug2 ?? `install#${opts.install}`,
9176
+ installOverride: true,
9177
+ requestOptions
9178
+ };
9179
+ }
8151
9180
  const manifest = readManifestOrThrow(opts.dir);
8152
9181
  const slug = manifest.module?.slug;
8153
9182
  if (!slug) {
@@ -8155,25 +9184,36 @@ function loadContext4(opts) {
8155
9184
  `module.json has no module.slug \u2014 the manifest is malformed.`
8156
9185
  );
8157
9186
  }
8158
- const cfg = readConfig();
8159
- if (!cfg) {
8160
- throw new Error("Not logged in. Run `dashwise login` first.");
8161
- }
8162
9187
  const entry = readModuleEntry(slug);
8163
9188
  if (!entry) {
8164
9189
  throw new Error(
8165
- `No dev install cached for module "${slug}". Run \`dashwise init ${slug} --force\` to provision one.`
9190
+ `No dev install cached for module "${slug}". Run \`dashwise init ${slug}\` to provision one (existing files are preserved), or pass \`--install <id>\` to target any installation (production included) \u2014 find ids via \`dashwise module status ${slug}\`.`
8166
9191
  );
8167
9192
  }
8168
9193
  return {
8169
9194
  installationId: entry.installationId,
8170
9195
  slug,
8171
- requestOptions: {
8172
- baseUrl: resolveApiUrl(),
8173
- auth: { token: cfg.token }
8174
- }
9196
+ installOverride: false,
9197
+ requestOptions
8175
9198
  };
8176
9199
  }
9200
+ async function resolveInstallKind(ctx) {
9201
+ if (!ctx.installOverride) return "local";
9202
+ const entry = readModuleEntry(ctx.slug);
9203
+ if (entry && entry.installationId === ctx.installationId) return "local";
9204
+ if (entry?.workspaceId !== void 0) {
9205
+ try {
9206
+ const rows = await listWorkspaceInstallations(
9207
+ entry.workspaceId,
9208
+ ctx.requestOptions
9209
+ );
9210
+ const row = rows.find((r) => r.id === ctx.installationId);
9211
+ if (row) return row.kind ?? "production";
9212
+ } catch {
9213
+ }
9214
+ }
9215
+ return void 0;
9216
+ }
8177
9217
  async function apiKeysListCommand(options) {
8178
9218
  let ctx;
8179
9219
  try {
@@ -8201,6 +9241,7 @@ async function apiKeysListCommand(options) {
8201
9241
  );
8202
9242
  return 0;
8203
9243
  }
9244
+ const installKind = await resolveInstallKind(ctx);
8204
9245
  info(`API keys for ${code(ctx.slug)} (install id=${ctx.installationId}):`);
8205
9246
  for (const k of filtered) {
8206
9247
  const status = formatKeyStatus(k);
@@ -8216,7 +9257,8 @@ async function apiKeysListCommand(options) {
8216
9257
  `);
8217
9258
  }
8218
9259
  } else if (k.grants !== void 0) {
8219
- process.stdout.write(` ${dim("full install access (no grants)")}
9260
+ const label = installKind === "local" ? "full own-table access (local dev install)" : "NO access \u2014 grantless key on a non-local install (mint with --grant or --full-access)";
9261
+ process.stdout.write(` ${dim(label)}
8220
9262
  `);
8221
9263
  }
8222
9264
  }
@@ -8228,6 +9270,12 @@ async function apiKeysCreateCommand(nameArg, options) {
8228
9270
  fail("Usage: `dashwise api-keys create <name>` (e.g. `dashwise api-keys create production`)");
8229
9271
  return 1;
8230
9272
  }
9273
+ if (options.fullAccess && options.grant && options.grant.length > 0) {
9274
+ fail(
9275
+ `${code("--full-access")} and ${code("--grant")} are mutually exclusive \u2014 pass one or the other.`
9276
+ );
9277
+ return 1;
9278
+ }
8231
9279
  let grants;
8232
9280
  try {
8233
9281
  grants = parseGrants(options.grant);
@@ -8249,8 +9297,18 @@ async function apiKeysCreateCommand(nameArg, options) {
8249
9297
  fail(err.message);
8250
9298
  return 1;
8251
9299
  }
9300
+ const installKind = await resolveInstallKind(ctx);
9301
+ const grantless = grants === void 0 && options.fullAccess !== true;
9302
+ if (grantless && installKind !== "local") {
9303
+ warn(
9304
+ `MINTING A DENY-ALL KEY: install ${code(String(ctx.installationId))} is ${installKind ? `kind=${installKind}` : "not the local dev install"}, and a grantless key on a non-local install can access NOTHING \u2014 every data-plane call will 403.
9305
+ Pass ${code("--full-access")} for full-CRUD over the module's own tables, or ${code("--grant <spec>")} for least-privilege scopes.
9306
+ (Grantless = full own-table access is a ${code("kind=local")} dev-install convenience only.)`
9307
+ );
9308
+ }
8252
9309
  const body = { name };
8253
9310
  if (grants !== void 0) body.grants = grants;
9311
+ if (options.fullAccess === true) body.full_own_tables = true;
8254
9312
  if (expiresAt !== void 0) body.expires_at = expiresAt;
8255
9313
  let resp;
8256
9314
  try {
@@ -8274,8 +9332,12 @@ async function apiKeysCreateCommand(nameArg, options) {
8274
9332
  if (grantLines.length > 0) {
8275
9333
  info(` ${dim("scope:")} scoped to`);
8276
9334
  for (const line of grantLines) info(` ${line}`);
9335
+ } else if (installKind === "local") {
9336
+ info(` ${dim("scope:")} full own-table access (grantless key on the local dev install)`);
8277
9337
  } else {
8278
- info(` ${dim("scope:")} full install access (no grants)`);
9338
+ info(
9339
+ ` ${dim("scope:")} NO access \u2014 grantless key on a non-local install (re-mint with ${code("--full-access")} or ${code("--grant")})`
9340
+ );
8279
9341
  }
8280
9342
  info("");
8281
9343
  warn(
@@ -8347,6 +9409,11 @@ function surfaceApiKeysError(err) {
8347
9409
  `A --grant referenced a table/app the backend rejected (${err.message}). Data Hub grants need a table or app that exists in this workspace; module grants need a table your manifest owns.`
8348
9410
  );
8349
9411
  return 1;
9412
+ case "GRANTS_CONFLICT":
9413
+ fail(
9414
+ `--full-access and --grant are mutually exclusive \u2014 pass one or the other.`
9415
+ );
9416
+ return 1;
8350
9417
  case "INSTALLATION_NOT_FOUND":
8351
9418
  fail(
8352
9419
  `Installation not found. It may have been destroyed; re-provision via ${code("dashwise init <slug>")}.`
@@ -8501,6 +9568,31 @@ init_config();
8501
9568
  init_manifest_io();
8502
9569
  init_output();
8503
9570
  function loadContext5(opts) {
9571
+ const cfg = readConfig();
9572
+ if (!cfg) {
9573
+ throw new Error("Not logged in. Run `dashwise login` first.");
9574
+ }
9575
+ const requestOptions = {
9576
+ baseUrl: resolveApiUrl(),
9577
+ auth: { token: cfg.token }
9578
+ };
9579
+ if (opts.install !== void 0) {
9580
+ if (!Number.isInteger(opts.install) || opts.install <= 0) {
9581
+ throw new Error(
9582
+ `--install expects a positive integer installation id (got "${opts.install}").`
9583
+ );
9584
+ }
9585
+ let slug2;
9586
+ try {
9587
+ slug2 = readManifest(opts.dir)?.module?.slug;
9588
+ } catch {
9589
+ }
9590
+ return {
9591
+ installationId: opts.install,
9592
+ slug: slug2 ?? `install#${opts.install}`,
9593
+ requestOptions
9594
+ };
9595
+ }
8504
9596
  const manifest = readManifestOrThrow(opts.dir);
8505
9597
  const slug = manifest.module?.slug;
8506
9598
  if (!slug) {
@@ -8508,23 +9600,16 @@ function loadContext5(opts) {
8508
9600
  `module.json has no module.slug \u2014 the manifest is malformed.`
8509
9601
  );
8510
9602
  }
8511
- const cfg = readConfig();
8512
- if (!cfg) {
8513
- throw new Error("Not logged in. Run `dashwise login` first.");
8514
- }
8515
9603
  const entry = readModuleEntry(slug);
8516
9604
  if (!entry) {
8517
9605
  throw new Error(
8518
- `No dev install cached for module "${slug}". Run \`dashwise init ${slug} --force\` to provision one.`
9606
+ `No dev install cached for module "${slug}". Run \`dashwise init ${slug}\` to provision one (existing files are preserved), or pass \`--install <id>\` to target any installation (production included) \u2014 find ids via \`dashwise module status ${slug}\`.`
8519
9607
  );
8520
9608
  }
8521
9609
  return {
8522
9610
  installationId: entry.installationId,
8523
9611
  slug,
8524
- requestOptions: {
8525
- baseUrl: resolveApiUrl(),
8526
- auth: { token: cfg.token }
8527
- }
9612
+ requestOptions
8528
9613
  };
8529
9614
  }
8530
9615
  function normalizeOrigin(raw) {
@@ -8719,7 +9804,7 @@ async function envExportCommand(options) {
8719
9804
  const entry = readModuleEntry(slug);
8720
9805
  if (!entry) {
8721
9806
  fail(
8722
- `No dev install cached for module "${slug}". Run \`dashwise init ${slug} --force\` to provision one.`
9807
+ `No dev install cached for module "${slug}". Run \`dashwise init ${slug}\` to provision one (existing files are preserved).`
8723
9808
  );
8724
9809
  return 1;
8725
9810
  }
@@ -8826,12 +9911,16 @@ function shellQuote(value) {
8826
9911
  }
8827
9912
 
8828
9913
  // src/commands/query.ts
9914
+ init_config();
9915
+ init_dotenv_local();
8829
9916
  init_manifest_io();
8830
9917
  init_output();
9918
+ init_dev();
8831
9919
  async function queryCommand(options) {
8832
9920
  const projectRoot = resolve(options.dir ?? process.cwd());
9921
+ let manifestSlug;
8833
9922
  try {
8834
- readManifestOrThrow(projectRoot);
9923
+ manifestSlug = readManifestOrThrow(projectRoot).module?.slug;
8835
9924
  } catch (err) {
8836
9925
  fail(err.message);
8837
9926
  return 1;
@@ -8849,6 +9938,32 @@ async function queryCommand(options) {
8849
9938
  return 1;
8850
9939
  }
8851
9940
  hydrateEnvFromDotenvLocal(projectRoot);
9941
+ if (!process.env.DASHWISE_API_KEY && manifestSlug) {
9942
+ const cfg = (() => {
9943
+ try {
9944
+ return readConfig();
9945
+ } catch {
9946
+ return null;
9947
+ }
9948
+ })();
9949
+ const apiUrl = process.env.DASHWISE_API_URL ?? cfg?.apiUrl ?? "http://localhost:3000";
9950
+ let entry = readModuleEntry(manifestSlug);
9951
+ if (cfg) {
9952
+ const healed = await resolveModuleApiKey(
9953
+ manifestSlug,
9954
+ entry,
9955
+ apiUrl,
9956
+ cfg.token
9957
+ );
9958
+ if (healed) entry = healed;
9959
+ }
9960
+ if (entry?.apiKey) {
9961
+ process.env.DASHWISE_API_KEY = entry.apiKey;
9962
+ if (!process.env.DASHWISE_API_URL) {
9963
+ process.env.DASHWISE_API_URL = apiUrl;
9964
+ }
9965
+ }
9966
+ }
8852
9967
  const modes = [
8853
9968
  options.inline !== void 0 ? "inline" : null,
8854
9969
  options.file !== void 0 ? "file" : null,
@@ -9025,25 +10140,6 @@ function generatedEntryUrl(generatedDir) {
9025
10140
  async function dynamicImportFromPath(generatedDir) {
9026
10141
  return await import(generatedEntryUrl(generatedDir));
9027
10142
  }
9028
- function hydrateEnvFromDotenvLocal(projectRoot) {
9029
- const envFile = join(projectRoot, ".env.local");
9030
- if (!existsSync(envFile)) return;
9031
- const raw = readFileSync(envFile, "utf-8");
9032
- for (const line of raw.split(/\r?\n/)) {
9033
- const trimmed = line.trim();
9034
- if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
9035
- const eq = trimmed.indexOf("=");
9036
- if (eq <= 0) continue;
9037
- const key = trimmed.slice(0, eq).trim();
9038
- let value = trimmed.slice(eq + 1).trim();
9039
- if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
9040
- value = value.slice(1, -1);
9041
- }
9042
- if (process.env[key] === void 0) {
9043
- process.env[key] = value;
9044
- }
9045
- }
9046
- }
9047
10143
 
9048
10144
  // src/commands/module/query-log.ts
9049
10145
  init_api_client();
@@ -9487,7 +10583,7 @@ var initOptions = (cmd) => cmd.option("--dir <path>", "Override the target direc
9487
10583
  "Target workspace for the dev installation (slug or numeric id). When omitted in a TTY, the CLI prompts; in non-interactive mode it auto-picks when you have one workspace and errors out otherwise."
9488
10584
  ).option(
9489
10585
  "--no-provision",
9490
- "Skip the `POST /api/installations/dev` step. Local scaffold is still written; you can provision later by re-running `dashwise init --force` (or, once it ships, `dashwise module provision-dev-install`)."
10586
+ "Skip the `POST /api/installations/dev` step. Local scaffold is still written; provision later by re-running `dashwise init <slug>` in the project dir (existing files are preserved \u2014 only missing ones are written)."
9491
10587
  ).addOption(
9492
10588
  new Option(
9493
10589
  "--deploy-target <platform>",
@@ -9754,6 +10850,14 @@ program.command("query").description(
9754
10850
  });
9755
10851
  }
9756
10852
  );
10853
+ program.command("run [cmd...]").description(
10854
+ "Run a command with the module's DashWise env injected \u2014 resolves DASHWISE_API_URL + DASHWISE_API_KEY through the same chain as `dashwise query` (explicit env \u2192 .env.local \u2192 the cached dwk_ key) and spawns the command with them overlaid, inheriting stdio and propagating the exit code. Example: `dashwise run -- node seed.mjs`."
10855
+ ).option("--dir <path>", "Project root containing `module.json` (default: current directory).").action(async (cmdArgs, cmdOpts) => {
10856
+ const { runCommand: runCommand2 } = await Promise.resolve().then(() => (init_run(), run_exports));
10857
+ process.exitCode = await runCommand2(cmdArgs ?? [], {
10858
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {}
10859
+ });
10860
+ });
9757
10861
  program.command("pull").description(
9758
10862
  "Sync local module.json from the backend. Fetches the canonical manifest for the current install and writes it to disk. Inverse of `dashwise table/field` commands."
9759
10863
  ).option("--dir <path>", "Project root containing `module.json` (default: current directory).").option("--dry-run", "Fetch + report drift but do not write to module.json.").action(async (cmdOpts) => {
@@ -9767,20 +10871,32 @@ var apiKeysCmd = program.command("api-keys").description(
9767
10871
  );
9768
10872
  apiKeysCmd.command("list").description(
9769
10873
  "List API keys for the current module's installation. Active only by default; pass --all for revoked too."
9770
- ).option("--dir <path>", "Project root (default: current directory).").option("--all", "Include revoked + expired keys in the output.").option("--json", "Emit JSON instead of the human-friendly table.").action(async (cmdOpts) => {
10874
+ ).option("--dir <path>", "Project root (default: current directory).").option(
10875
+ "--install <id>",
10876
+ "Target a specific installation id instead of the cached dev install (production installs included). Find ids via `dashwise module status <slug>`.",
10877
+ parseIntOption
10878
+ ).option("--all", "Include revoked + expired keys in the output.").option("--json", "Emit JSON instead of the human-friendly table.").action(async (cmdOpts) => {
9771
10879
  process.exitCode = await apiKeysListCommand({
9772
10880
  ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
10881
+ ...cmdOpts.install !== void 0 ? { install: cmdOpts.install } : {},
9773
10882
  ...cmdOpts.all !== void 0 ? { all: cmdOpts.all } : {},
9774
10883
  ...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
9775
10884
  });
9776
10885
  });
9777
10886
  apiKeysCmd.command("create [name]").description(
9778
10887
  "Mint a new API key. The plaintext key is displayed ONCE \u2014 save it then."
9779
- ).option("--dir <path>", "Project root (default: current directory).").option("--name <name>", "Override the positional name arg (e.g. for scripted use).").option(
10888
+ ).option("--dir <path>", "Project root (default: current directory).").option(
10889
+ "--install <id>",
10890
+ "Target a specific installation id instead of the cached dev install (production installs included). Find ids via `dashwise module status <slug>`.",
10891
+ parseIntOption
10892
+ ).option("--name <name>", "Override the positional name arg (e.g. for scripted use).").option(
9780
10893
  "--grant <spec>",
9781
- "Scoped grant (repeatable). `module:<tableSlug>:<ops>`, `datahub:table=<id>:<ops>`, `datahub:app=<id>:<ops>`, or `datahub:workspace:<ops>` where <ops> is a comma list of create,read,update,delete. Any grant mints a scoped (default-deny) key (workspace-admin required). Omit for a full-access key.",
10894
+ "Scoped grant (repeatable). `module:<tableSlug>:<ops>`, `datahub:table=<id>:<ops>`, `datahub:app=<id>:<ops>`, or `datahub:workspace:<ops>` where <ops> is a comma list of create,read,update,delete. Any grant mints a scoped (default-deny) key (workspace-admin required). Mutually exclusive with --full-access.",
9782
10895
  collectRepeated,
9783
10896
  []
10897
+ ).option(
10898
+ "--full-access",
10899
+ "Mint with server-enumerated full-CRUD grants over every table the pinned manifest owns \u2014 the honest deploy-key default (a grantless key on a NON-local install is deny-all). Mutually exclusive with --grant."
9784
10900
  ).option(
9785
10901
  "--expires <days>",
9786
10902
  "Days until the key expires (converted to an absolute timestamp). Default: never expires.",
@@ -9792,8 +10908,10 @@ apiKeysCmd.command("create [name]").description(
9792
10908
  async (nameArg, cmdOpts) => {
9793
10909
  process.exitCode = await apiKeysCreateCommand(nameArg, {
9794
10910
  ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
10911
+ ...cmdOpts.install !== void 0 ? { install: cmdOpts.install } : {},
9795
10912
  ...cmdOpts.name !== void 0 ? { name: cmdOpts.name } : {},
9796
10913
  ...cmdOpts.grant !== void 0 && cmdOpts.grant.length > 0 ? { grant: cmdOpts.grant } : {},
10914
+ ...cmdOpts.fullAccess !== void 0 ? { fullAccess: cmdOpts.fullAccess } : {},
9797
10915
  ...cmdOpts.expires !== void 0 ? { expires: cmdOpts.expires } : {},
9798
10916
  ...cmdOpts.rawOnly !== void 0 ? { rawOnly: cmdOpts.rawOnly } : {}
9799
10917
  });
@@ -9801,10 +10919,15 @@ apiKeysCmd.command("create [name]").description(
9801
10919
  );
9802
10920
  apiKeysCmd.command("revoke [key-id]").description(
9803
10921
  "Soft-revoke an API key. Backend keeps the row for audit; further requests return 401."
9804
- ).option("--dir <path>", "Project root (default: current directory).").option("-y, --yes", "Skip the per-key confirmation message (for CI / scripts).").action(
10922
+ ).option("--dir <path>", "Project root (default: current directory).").option(
10923
+ "--install <id>",
10924
+ "Target a specific installation id instead of the cached dev install (production installs included). Find ids via `dashwise module status <slug>`.",
10925
+ parseIntOption
10926
+ ).option("-y, --yes", "Skip the per-key confirmation message (for CI / scripts).").action(
9805
10927
  async (keyIdArg, cmdOpts) => {
9806
10928
  process.exitCode = await apiKeysRevokeCommand(keyIdArg, {
9807
10929
  ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
10930
+ ...cmdOpts.install !== void 0 ? { install: cmdOpts.install } : {},
9808
10931
  ...cmdOpts.yes !== void 0 ? { yes: cmdOpts.yes } : {}
9809
10932
  });
9810
10933
  }
@@ -9812,22 +10935,37 @@ apiKeysCmd.command("revoke [key-id]").description(
9812
10935
  var originsCmd = program.command("origins").description(
9813
10936
  "Manage the current module install's SSO callback-origin allowlist. Local dev (localhost) is always allowed; add deployed domains here."
9814
10937
  );
9815
- originsCmd.command("list").description("Show the SSO callback origins configured for the current module install.").option("--dir <path>", "Project root (default: current directory).").option("--json", "Emit a JSON array instead of the human-friendly list.").action(async (cmdOpts) => {
10938
+ originsCmd.command("list").description("Show the SSO callback origins configured for the current module install.").option("--dir <path>", "Project root (default: current directory).").option(
10939
+ "--install <id>",
10940
+ "Target a specific installation id instead of the cached dev install (production installs included). Find ids via `dashwise module status <slug>`.",
10941
+ parseIntOption
10942
+ ).option("--json", "Emit a JSON array instead of the human-friendly list.").action(async (cmdOpts) => {
9816
10943
  process.exitCode = await originsListCommand({
9817
10944
  ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
10945
+ ...cmdOpts.install !== void 0 ? { install: cmdOpts.install } : {},
9818
10946
  ...cmdOpts.json !== void 0 ? { json: cmdOpts.json } : {}
9819
10947
  });
9820
10948
  });
9821
10949
  originsCmd.command("add <url>").description(
9822
10950
  "Add an SSO callback origin (e.g. https://app.example.com). Idempotent; non-localhost origins must be https://."
9823
- ).option("--dir <path>", "Project root (default: current directory).").action(async (url, cmdOpts) => {
10951
+ ).option("--dir <path>", "Project root (default: current directory).").option(
10952
+ "--install <id>",
10953
+ "Target a specific installation id instead of the cached dev install (production installs included). Find ids via `dashwise module status <slug>`.",
10954
+ parseIntOption
10955
+ ).action(async (url, cmdOpts) => {
9824
10956
  process.exitCode = await originsAddCommand(url, {
9825
- ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {}
10957
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
10958
+ ...cmdOpts.install !== void 0 ? { install: cmdOpts.install } : {}
9826
10959
  });
9827
10960
  });
9828
- originsCmd.command("remove <url>").description("Remove an SSO callback origin from the allowlist.").option("--dir <path>", "Project root (default: current directory).").action(async (url, cmdOpts) => {
10961
+ originsCmd.command("remove <url>").description("Remove an SSO callback origin from the allowlist.").option("--dir <path>", "Project root (default: current directory).").option(
10962
+ "--install <id>",
10963
+ "Target a specific installation id instead of the cached dev install (production installs included). Find ids via `dashwise module status <slug>`.",
10964
+ parseIntOption
10965
+ ).action(async (url, cmdOpts) => {
9829
10966
  process.exitCode = await originsRemoveCommand(url, {
9830
- ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {}
10967
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
10968
+ ...cmdOpts.install !== void 0 ? { install: cmdOpts.install } : {}
9831
10969
  });
9832
10970
  });
9833
10971
  var envCmd = program.command("env").description("Environment-variable helpers for deploying a module.");
@@ -9881,7 +11019,7 @@ moduleCmd.command("install [slug]").alias("deploy").description(
9881
11019
  "Install (deploy) a published module into a workspace \u2014 provisions the runtime and prints the public URL. Upgrades/repairs an existing production install idempotently. Refuses to silently land on a dev install: use --replace-dev-install or --workspace <other>."
9882
11020
  ).option("--workspace <slug-or-id>", "Target workspace (default: cached module workspace, config default, or interactive picker).").option("--version <semver>", "Install a specific published version (default: latest).").option("--force", "Proceed even when the upgrade breaks consumer modules (UPGRADE_BREAKS_CONSUMERS).").option(
9883
11021
  "--replace-dev-install",
9884
- "Destroy the workspace's kind=local dev install first, then fresh production install. Revokes the module's cached local-dev credentials (re-run `dashwise init --force` for local dev afterwards)."
11022
+ "Destroy the workspace's kind=local dev install first, then fresh production install. PERMANENTLY disables local dev for this module in this workspace (credentials revoked; a published module cannot be re-provisioned here) \u2014 continue development in a different workspace."
9885
11023
  ).option("--dir <path>", "Project root for the module.json slug lookup (default: current directory).").option("-y, --yes", "Skip the confirmation prompt (for CI / scripted use).").option("--json", "Emit the final result as machine-readable JSON.").action(
9886
11024
  async (slug, cmdOpts) => {
9887
11025
  process.exitCode = await moduleInstallCommand(slug, {
@@ -9895,6 +11033,21 @@ moduleCmd.command("install [slug]").alias("deploy").description(
9895
11033
  });
9896
11034
  }
9897
11035
  );
11036
+ moduleCmd.command("uninstall [slug]").description(
11037
+ "Uninstall a module from a workspace. The app is soft-deleted \u2014 recoverable ONLY by a platform operator (contact support/HQ) within ~7 days; it does NOT appear in your dashboard trash. An operator restore keeps the public URL; a reinstall mints a NEW one. Refuses the kind=local dev install: use `dashwise module destroy-dev-install` for that. Interactive runs confirm twice (including typing the slug); scripts must pass BOTH --yes and an explicit --workspace."
11038
+ ).option("--workspace <slug-or-id>", "Target workspace (default: cached module workspace, config default, or interactive picker).").option(
11039
+ "--park-consumers",
11040
+ "Uninstall even when live consumer modules depend on this install \u2014 their dependencies are parked (PROVIDER_UNINSTALLED) instead of blocking with PROVIDER_HAS_CONSUMERS."
11041
+ ).option("--dir <path>", "Project root for the module.json slug lookup (default: current directory).").option("-y, --yes", "Skip the confirmation prompts. Only honored together with an explicit --workspace.").action(
11042
+ async (slug, cmdOpts) => {
11043
+ process.exitCode = await moduleUninstallCommand(slug, {
11044
+ ...cmdOpts.workspace !== void 0 ? { workspace: cmdOpts.workspace } : {},
11045
+ ...cmdOpts.parkConsumers !== void 0 ? { parkConsumers: cmdOpts.parkConsumers } : {},
11046
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
11047
+ ...cmdOpts.yes !== void 0 ? { yes: cmdOpts.yes } : {}
11048
+ });
11049
+ }
11050
+ );
9898
11051
  moduleCmd.command("status [slug]").description(
9899
11052
  "Show a module's deploy state in a workspace: install kind, installed vs latest published version, runtime provisioning, and the public URL."
9900
11053
  ).option("--workspace <slug-or-id>", "Target workspace (default: cached module workspace, config default, or interactive picker).").option("--dir <path>", "Project root for the module.json slug lookup (default: current directory).").option("--json", "Emit machine-readable JSON.").action(
@@ -9978,7 +11131,7 @@ moduleCmd.command("destroy-dev-install").description(
9978
11131
  }
9979
11132
  );
9980
11133
  moduleCmd.command("reset-dev-install").description(
9981
- "Destroy + re-provision the local-dev install in one shot. Useful for nuking accumulated test data, refreshing a compromised runtime token, or recovering from a wedged schema state. Keeps local module.json intact."
11134
+ "Destroy + re-provision the local-dev install in one shot. Useful for nuking accumulated test data, refreshing a compromised runtime token, or recovering from a wedged schema state. Provision-only re-init: ALL local files (module.json, app code) are left untouched \u2014 only the backend install + cached credentials are recreated."
9982
11135
  ).option("--dir <path>", "Project root (default: current directory).").option(
9983
11136
  "--module <slug>",
9984
11137
  "Override the module slug (default: from local module.json)."
@@ -9996,6 +11149,18 @@ moduleCmd.command("reset-dev-install").description(
9996
11149
  });
9997
11150
  }
9998
11151
  );
11152
+ var schemaCmd = program.command("schema").description(
11153
+ "Whole-manifest schema commands. `schema push` applies the local module.json to the dev install (the missing write half of the sync loop \u2014 hand-edited rbac/indexes/table blocks finally reach the backend)."
11154
+ );
11155
+ schemaCmd.command("push").description(
11156
+ "Apply the LOCAL module.json to the dev install via PUT /api/modules/:id/draft/manifest (greenfield or delta). Destructive removals (tables/fields) are previewed and require confirmation unless --yes. Afterwards the canonical manifest is synced back with merge semantics (local-only rbac/indexes blocks survive)."
11157
+ ).option("--dir <path>", "Project root containing `module.json` (default: current directory).").option("-y, --yes", "Skip the destructive-change confirmation (CI / scripted runs).").action(async (cmdOpts) => {
11158
+ const { schemaPushCommand: schemaPushCommand2 } = await Promise.resolve().then(() => (init_push(), push_exports));
11159
+ process.exitCode = await schemaPushCommand2({
11160
+ ...cmdOpts.dir !== void 0 ? { dir: cmdOpts.dir } : {},
11161
+ ...cmdOpts.yes !== void 0 ? { yes: cmdOpts.yes } : {}
11162
+ });
11163
+ });
9999
11164
  var tableCmd = program.command("table").description(
10000
11165
  "Schema commands for the current module \u2014 create/delete tables in the dev installation. Requires a per-module dev install (run `dashwise init` first)."
10001
11166
  );
@@ -10033,7 +11198,7 @@ var fieldCmd = program.command("field").description(
10033
11198
  "Schema commands for fields within the current module \u2014 add/remove/change-type. Field rename is deferred to v1.1 per the plan."
10034
11199
  );
10035
11200
  fieldCmd.command("add <table-slug> [field-spec]").description(
10036
- 'Add a field to a table. Pass the spec as `<slug>:<type>[:required]` OR use --field-json for fields with options/target/default. Example: `dashwise field add tasks priority --field-json \'{"slug":"priority","type":"single_select","options":["low","medium","high"]}\'`.'
11201
+ 'Add a field to a table. Pass the spec as `<slug>:<type>[:required]` OR use --field-json for fields with options/target/default (never both \u2014 the slug lives inside the JSON). Examples: `dashwise field add tasks priority:text` or `dashwise field add tasks --field-json \'{"slug":"priority","type":"single_select","options":["low","medium","high"]}\'`.'
10037
11202
  ).option("--dir <path>", "Project root (default: current directory).").option(
10038
11203
  "--field-json <json>",
10039
11204
  "Field as a JSON object. Mutually exclusive with the positional field spec."
@@ -10083,7 +11248,7 @@ fieldCmd.command("set-type <table-slug> <field-slug> <new-type>").description(
10083
11248
  }
10084
11249
  })();
10085
11250
  function getVersion() {
10086
- return "0.14.2";
11251
+ return "0.16.0";
10087
11252
  }
10088
11253
  function parseIntOption(value) {
10089
11254
  const n = Number.parseInt(value, 10);